Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions docs/licenses/ISSUE_569_SIMILARITY_AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Issue #569 — full-tree similarity audit against the Without Number books

**What this is.** The rights matrix ([`ISSUE_569_WITHOUT_NUMBER_RIGHTS.md`](ISSUE_569_WITHOUT_NUMBER_RIGHTS.md))
closed every item of #569 except one, in its own words: *"a complete repository-wide similarity audit
remains outstanding"*. This is that audit, run on **2026-10-25**, with its method, corpus fingerprints
and measured result.

It changes no file outside this record, deletes nothing, and claims no licence.

---

## 1. Why not a keyword search

The rights matrix already refuses that shortcut: a name/keyword ban *"would falsely reject legitimate
attribution and miss paraphrased copying"*. Both halves are real. A search for "Without Number" hits
this very document; a search for distinctive nouns hits our own setting vocabulary. And neither finds
a lifted sentence that changed the character names.

So the audit measures **n-gram containment**: does any file in this tree contain a run of **N
consecutive words** that also appears in a licence-blocked source? `N = 10` — long enough that a shared
run is copying rather than coincidence, short enough to catch a lifted fragment inside a longer
sentence. Normalisation is lowercase word tokens with punctuation dropped, so reformatting,
re-wrapping and case changes do not hide anything.

## 2. Two corpora, because a match is not a verdict

Four books, two licence statuses, and the distinction decides whether a hit is a problem:

| corpus | documents | status | what a hit means |
| --- | --- | --- | --- |
| **blocked** | Stars Without Number Revised (free ed., 261 pp); Ashes Without Number (free ed., 261 pp) | no SRD located, no licence statement in either file | a hit here is the thing the audit exists to catch |
| **attributable** | Cities Without Number SRD; Worlds Without Number SRD (both CC0) | CC0 — reusable with attribution | a hit that *also* occurs here is **attributable**, not a violation |

The comparison corpus is a hash set. **No third-party text is stored, printed or committed** — the
report carries our path, the line, the run length and the classification, never the matched words.

Corpus fingerprints, so a later run can prove it used the same books:

| document | sha256 |
| --- | --- |
| `StarsWithoutNumberRevised-FreeEdition-122917.pdf` | `22ef6826a5b350d66888eef386b3c7f8bf02f091c18183a241e52c3cf2f44a3f` |
| `AshesWithoutNumber_FreeVersion_071025.pdf` | `033766a846f3106b72a8f72876f4dc98d319102cfc97fc39e6b79ffa9c58d3a6` |
| CWN SRD (`CWN-SRD-V5` markdown, 65 468 unique 10-grams) | repository snapshot, 2026-10-25 |
| WWN SRD (`WWNSRDV1` markdown, 80 759 unique 10-grams) | repository snapshot, 2026-10-25 |

## 3. Result

```
$ python3 tools/audit_without_number_expression.py \
--source StarsWithoutNumberRevised-FreeEdition-122917.pdf \
--source AshesWithoutNumber_FreeVersion_071025.pdf \
--cc0-source CWN-SRD-V5 --cc0-source WWNSRDV1
```

| quantity | value |
| --- | ---: |
| text files scanned (`.md .py .json .yaml .yml .gd .txt .html`, excluding `.git`/caches) | **946** |
| unique blocked-corpus 10-word runs | 373 766 |
| unique attributable-corpus 10-word runs | 138 919 |
| **first-party hits** | **0** |
| first-party blocked-only hits | **0** |
| quarantined hits | 32, all in one file |
| quarantined blocked-only hits | **0** |

**The first-party tree is clean**: no file outside the preserved corpora shares a ten-word run with
either blocked book.

### The one finding, stated precisely

`docs/archive/RULEBOOK.md` — the archived pre-#120 rulebook — contains **32** ten-word runs that also
occur in the blocked corpus. **All 32 also occur in the CC0 SRD corpus**, so they are attributable SRD
wording (the family's shared skill-level table and skill descriptions), not a copy of protected
expression. The file is additionally inside the `archive/` quarantine, which the release policy already
excludes from packaging.

Disposition: **report, do not delete**. The file is preserved history, the overlap is licence-permitted
with attribution, and the live rulebook (`RULEBOOK.md`, `rulebook/`) has **zero** hits. If a future
release ever includes the archive, those 32 runs need the CWN/WWN attribution notice — which
`docs/sources/ATTRIBUTION.md` is the place for.

## 4. Enforcement, and the honest limit

- **The corpus is not shippable.** The books are copyrighted products on the operator's disk; embedding
their text (or a shingle fingerprint derived from it) in the repository would be the very thing this
audit prevents. **This makes a similarity gate impossible in CI**, and the audit says so instead of
pretending otherwise.
- **The tool is therefore an operator tool**, documented and tested (`tests/test_audit_without_number_expression.py`,
9 code tests) so the run is reproducible by anyone holding the books.
- **The CI-side gate remains `tools/release_packaging_guard.py`** — manifest, resolved author licence,
quarantine classes, and the `LICENSE`/`NOTICE` absence rule. It does not and cannot detect n-gram
overlap; #569's item 4 is satisfied by the release gate plus this operator audit, and the division of
labour is stated here rather than implied.
- **Re-run trigger:** a new edition of any book (fingerprints above change), a new SRD, or any future
import of third-party prose. The audit is cheap: 22 seconds for 946 files against 373 k shingles.

## 5. What was not done, and why

- **No paraphrase detection.** Containment catches verbatim runs. A genuine paraphrase detector is an
LLM-assisted similarity review; it is out of scope for an offline deterministic tool, and the rights
matrix's own rule (human review before admitting text) remains the control that matters.
- **No archive remediation.** Deleting preserved history is forbidden by the repo's own policy
(*"report rather than silently delete"*); the overlap is recorded instead.
- **No licence selection.** D10 stays the author's; nothing here creates `LICENSE` or `NOTICE`.

— Ai (愛)
11 changes: 11 additions & 0 deletions scripts/noopunk_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import argparse
import random
from pathlib import Path
import sys

Expand Down Expand Up @@ -86,19 +87,24 @@ def _build_engine(args: argparse.Namespace) -> tuple[GameEngine, object | None,
controllers[STARGATE_ANALYST_ID] = llm
controllers[MARS_ARCHIVE_ID] = llm

# A seeded run is replayable (#570); an unseeded one uses the global generator, like a tabletop.
rng = random.Random(args.seed) if args.seed is not None else None

if args.load:
engine = load_game(
args.load,
connection=connection,
controllers=controllers,
gm_controller=llm if args.llm_gm else None,
rng=rng,
)
else:
engine = GameEngine(
world=world,
connection=connection,
controllers=controllers,
gm_controller=llm if args.llm_gm else None,
rng=rng,
)
return engine, llm, human_id

Expand All @@ -114,6 +120,10 @@ def main() -> int:
parser.add_argument("--db", default="", help="SQLite event-log path (default: in-memory)")
parser.add_argument("--load", default="", help="Load JSON save")
parser.add_argument("--save", default="noopunk-save.json", help="Default JSON save path")
parser.add_argument(
"--seed", type=int, default=None,
help="Seed the layer-A dice so a run is replayable; omit for tabletop-style randomness",
)
parser.add_argument("--ollama", action="store_true", help="Enable optional Ollama/Concordia actors")
parser.add_argument("--llm-gm", action="store_true", help="Use the Ollama controller for brief GM narration")
args = parser.parse_args()
Expand Down Expand Up @@ -170,6 +180,7 @@ def main() -> int:
connection=engine.connection,
controllers=engine.controllers,
gm_controller=engine.gm_controller,
rng=engine.rng,
)
print(f"Loaded: {path}")
print(engine.describe_room(human_id))
Expand Down
6 changes: 6 additions & 0 deletions src/text_game/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ class GameEngine:
connection: sqlite3.Connection | None = None
controllers: dict[str, Any] = field(default_factory=dict)
gm_controller: Any | None = None
#: Dice source for the layer-A kernel. `None` means the global `random`, which is what a
#: tabletop session does; `random.Random(seed)` makes a run replayable (#570).
rng: Any | None = None
events: list[Event] = field(default_factory=list)

def __post_init__(self) -> None:
Expand Down Expand Up @@ -363,6 +366,9 @@ def _resolve_test_verb(self, actor: ActorState, args: tuple[str, ...], action: A
"skill": skill,
"modifier": modifier,
"roll": roll,
# A pinned `roll` still wins inside the kernel; otherwise the engine's own dice source
# decides, so a seeded run and a tabletop run use the same path.
"rng": self.rng,
}
if defender is not None:
kwargs["defender_sheet"] = defender[1]
Expand Down
2 changes: 2 additions & 0 deletions src/text_game/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def load_game(
connection: sqlite3.Connection | None = None,
controllers: dict | None = None,
gm_controller=None,
rng=None,
) -> GameEngine:
payload = json.loads(Path(path).read_text(encoding="utf-8"))
events = [Event.from_dict(item) for item in payload.get("events", [])]
Expand All @@ -43,6 +44,7 @@ def load_game(
connection=connection,
controllers=dict(controllers or {}),
gm_controller=gm_controller,
rng=rng,
events=events,
)
if connection is not None and events:
Expand Down
21 changes: 21 additions & 0 deletions strategy_game/tests/test_deterministic_playthroughs.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,27 @@ def __init__(
save_name="unused.json",
saves=False,
),
# --- layer A, the legacy tabletop runtime (#570 lists it explicitly) -------------------
# `scripts/noopunk_text.py --seed` is what makes these replayable: the layer-A kernel
# otherwise consumes the global generator, so two runs would differ on the roll line.
# The save files are not compared: `simulation_id` is minted per process, so the JSON is
# deliberately unique even when the play is identical (the play is what is asserted).
_Run(
name="legacy layer A: prefall (seeded)",
argv=[str(ROOT / "scripts" / "noopunk_text.py"), "--scenario", "prefall", "--seed", "42"],
stdin="look\ntest skill Perceive\nquit\n",
marker="Perceive test:",
save_name="layer-a-prefall.json",
saves=False,
),
_Run(
name="legacy layer A: issue74 (seeded)",
argv=[str(ROOT / "scripts" / "noopunk_text.py"), "--scenario", "issue74", "--seed", "7"],
stdin="look\ntest skill Perceive\nquit\n",
marker="Perceive test:",
save_name="layer-a-issue74.json",
saves=False,
),
)


Expand Down
Loading
Loading