From 60f3ac999fb5254d507cb3c622fcda88e7b72285 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:33:39 -0700 Subject: [PATCH 01/15] feat(detectors): pin and version the thresholds behind the non-spine edges #1283 restated AC2 in two halves. The recompute half shipped in #1336; this is the other one. Edges that are neither TEMPORAL_NEXT nor DERIVED_FROM are a function of the belief set AND of detector thresholds, so "edges are recomputable" holds for them only if those thresholds are pinned and versioned. They were bare module constants with no guard. detector_thresholds records 22 constants across 8 modules behind DETECTOR_THRESHOLDS_VERSION: the relationship_detector cutoffs, caps and vocabularies, the contradiction precedence ladder, the triple_extractor phrase-to-edge-type patterns, the value_compare slot gate, and the constants on the two paths that decide which phantoms reach the RELATES_TO writer. The module holds hand-written literals and imports nothing from aelfrice. That is deliberate: importing the constants it describes would make it tautological in exactly the way the tests it replaces were, and would drag store/bm25 into the import graph of any reader. Scalars pin as literals so a reviewer can check them by eye; collections pin as a digest of a canonical form that includes regex flags, since dropping re.IGNORECASE changes which triples match without changing any pattern text. Entries were checked for reachability rather than assumed. The wonder bake-off constants are NOT pinned -- the package docstring states the strategies are research-only and their sole importer builds against an in-memory store, so they decide no edge a user holds. The value_compare entries are pinned but labelled dormant: nothing shipped passes use_value_comparison=True. Three upstream suppliers the call-site sweep cannot see (bm25._TOKEN_PATTERN, models.ANCHOR_TEXT_MAX_LEN, wonder_consolidation._TOKENIZER_DROP) are pinned by hand, and the two suppliers left unpinned are named so their absence is a decision. Forward-only. The edges table has no version and no created_at, so this does not make a historical edge attributable to the thresholds that produced it; adding those columns is the migration that bricked stores in #1161, and historical reproduction stays out of scope. --- src/aelfrice/detector_thresholds.py | 671 ++++++++++++++++++++++++++++ 1 file changed, 671 insertions(+) create mode 100644 src/aelfrice/detector_thresholds.py diff --git a/src/aelfrice/detector_thresholds.py b/src/aelfrice/detector_thresholds.py new file mode 100644 index 000000000..1cc0e5942 --- /dev/null +++ b/src/aelfrice/detector_thresholds.py @@ -0,0 +1,671 @@ +"""Frozen, versioned record of the constants that decide the non-spine edge set. + +#1283 restated AC2 in two halves. The recompute half — the deterministic +replay of the ``TEMPORAL_NEXT`` spine keyed on ``(created_at, ingest_log +ULID)`` — shipped in #1336. This module is the other half (#1355). + +**Why it exists.** The edges that are *not* ``TEMPORAL_NEXT`` and not +``DERIVED_FROM`` (2.8% of the table: ``CONTRADICTS``, ``SUPERSEDES``, +``RELATES_TO``, ``TESTS``, ``CITES``, ``SUPPORTS``, ``IMPLEMENTS``, +``RESOLVES``, ``POTENTIALLY_STALE``) are a function of the belief set +**and** of detector thresholds. "Edges are recomputable" is therefore only +true if those thresholds are pinned and versioned. Before this module they +were bare module constants that could drift silently: the tests that +nominally covered them compared a constant to itself +(``assert cfg.jaccard_min == DEFAULT_JACCARD_MIN``), which survives any +change to what the symbol resolves to. + +**What this module is.** A hand-written record of the shipped values, held +as *literals*. It deliberately imports nothing from ``aelfrice`` — if it +imported the constants it would describe, it would be tautological in +exactly the way the tests it replaces were, and it would drag +``store``/``bm25`` into the import graph of anything that reads the +manifest. The comparison against live source is done by +``tests/test_detector_thresholds_manifest_1355.py``, which imports each +constant by name and re-derives its pinned form with :func:`pin_value`. + +**Forward-only.** Pinning forward does not make the past reproducible: the +``edges`` table carries no version and no ``created_at``, so a historical +edge cannot be attributed to the thresholds that produced it. Adding those +columns is an ``edges``-table migration — the operation that left stores +unopenable-forever in #1161 — and historical reproduction is explicitly +out of scope here. What this buys is that from ``DETECTOR_THRESHOLDS_VERSION += 1`` onward, a change to any listed value cannot land without bumping the +version, because :data:`MANIFEST_DIGEST` goes red. + +**Honesty about overrides.** Several entries are defaults that a config +file or environment variable can override at runtime — ``jaccard_min``, +``confidence_min`` and ``max_candidate_pairs`` are readable from +``[relationship_detector]`` in ``.aelfrice.toml``. For those, the manifest +pins the *shipped default*, not the value a given store actually ran with. +That is a real limit and the ``overridable`` field on every entry names the +mechanism, so a reader can tell the two apart rather than over-trusting the +record. + +**What this cannot pin.** Entries are resolved by ``(module, name)``, so a +value with no name is out of reach. Three writers stamp an edge weight as a +literal inside the ``Edge(...)`` constructor — ``relationship_detector`` +(both writers), ``triple_extractor`` and ``wonder.lifecycle`` all pass +``weight=1.0`` inline. ``contradiction`` is the one that names it +(``SUPERSEDES_WEIGHT``), which is why it appears below and the others do +not. Naming those three would be a behaviour-preserving refactor and would +close the gap; it is deliberately not bundled into this change. Constants +reached only transitively are a different case and *are* covered: +``_QUANTIFIER_TOKENS`` is derived from ``QUANT_AXIS``, and the noun-phrase +regex fragments are compiled into ``_PATTERNS``, so both move their +digests already. + +**Writers versus suppliers.** The coverage test sweeps ``insert_edge`` call +sites, so it can only see modules that *write*. A module that merely +*supplies the decision* is invisible to it and has to be added by hand. +Three such are pinned below — ``bm25._TOKEN_PATTERN``, +``models.ANCHOR_TEXT_MAX_LEN`` and ``wonder_consolidation._TOKENIZER_DROP`` +— because each moves edges in every writer downstream of it. Two are known +and deliberately left out: ``dedup`` supplies the candidate-pair prefilter +whose semantics differ from the detector's own (empty-versus-empty scores +1.0 there and 0.0 here, and blank-content beliefs are skipped before +scoring), and ``config_discovery`` decides *which* ``.aelfrice.toml`` +supplies the overridable values above. Both are behavioural surfaces +rather than constants; pinning them means pinning functions, which this +mechanism does not do. They are named here so their absence is a recorded +decision rather than an oversight. +""" +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Sequence +from dataclasses import dataclass, fields, is_dataclass +from typing import Any, Final, cast + +# Bump when any pinned value below changes. The digest guard in +# tests/test_detector_thresholds_manifest_1355.py makes this mechanical: +# edit a value without bumping, and the test names both. +DETECTOR_THRESHOLDS_VERSION: Final[int] = 1 + +# --- Kinds ------------------------------------------------------------- + +KIND_CUTOFF: Final[str] = "numeric_cutoff" +KIND_CAP: Final[str] = "cap" +KIND_WEIGHT: Final[str] = "weight" +KIND_PATTERN_TABLE: Final[str] = "pattern_table" +KIND_TOKEN_SET: Final[str] = "token_set" +# A bare string constant that is neither a cutoff nor a collection. +KIND_LITERAL: Final[str] = "literal" + +KINDS: Final[frozenset[str]] = frozenset({ + KIND_CUTOFF, + KIND_CAP, + KIND_WEIGHT, + KIND_PATTERN_TABLE, + KIND_TOKEN_SET, + KIND_LITERAL, +}) + +# Override mechanisms, as strings so the manifest stays literal. +OVERRIDE_NONE: Final[str] = "no" +OVERRIDE_TOML: Final[str] = "toml:[relationship_detector]" +OVERRIDE_KWARG: Final[str] = "kwarg" + + +# --- Canonicalisation -------------------------------------------------- + + +def _canonical(obj: Any) -> Any: + """Reduce a constant to a JSON-able form with a stable ordering. + + Sets and dicts are order-unstable across builds, so both are sorted. + Compiled patterns reduce to ``(pattern, flags)`` — the flags matter: + dropping ``re.IGNORECASE`` changes which triples match, and therefore + which edges get written, without touching the pattern text. + """ + if isinstance(obj, re.Pattern): + pattern = cast("re.Pattern[str]", obj) + return {"regex": pattern.pattern, "flags": int(pattern.flags)} + if is_dataclass(obj) and not isinstance(obj, type): + return { + f.name: _canonical(getattr(obj, f.name)) + for f in sorted(fields(obj), key=lambda f: f.name) + } + if isinstance(obj, (frozenset, set)): + members = cast("frozenset[Any]", obj) + return sorted(_canonical(x) for x in members) + if isinstance(obj, dict): + mapping = cast("dict[str, Any]", obj) + return [[k, _canonical(v)] for k, v in sorted(mapping.items())] + if isinstance(obj, (tuple, list)): + seq = cast("Sequence[Any]", obj) + return [_canonical(x) for x in seq] + if isinstance(obj, (bool, int, float, str)) or obj is None: + return obj + raise TypeError(f"not canonicalisable: {type(obj).__name__}") + + +def _dumps(obj: Any) -> str: + return json.dumps(obj, sort_keys=True, separators=(",", ":")) + + +def pin_value(obj: Any) -> str: + """Return the pinned form of a live constant. + + Scalars pin as their literal, so the manifest stays readable and a + reviewer can check ``0.4`` against the source line by eye. Collections + pin as a digest of their canonical form — a stopword set is not + reviewable inline, but its digest changes the moment a token is added + or removed, which is the property the manifest needs. + """ + if isinstance(obj, bool) or not isinstance(obj, (int, float, str)): + return "sha256:" + hashlib.sha256( + _dumps(_canonical(obj)).encode("utf-8") + ).hexdigest() + return _dumps(obj) + + +def size_of(obj: Any) -> int | None: + """Element count for a collection; ``None`` for a scalar. + + Recorded alongside the digest because a digest mismatch alone says + "something moved" — the count says whether the set grew or shrank, + which is usually the first thing a reviewer wants to know. + """ + if isinstance(obj, (str, bytes)) or isinstance(obj, re.Pattern): + return None + if isinstance(obj, bool) or isinstance(obj, (int, float)): + return None + try: + return len(obj) + except TypeError: + return None + + +# --- The manifest ------------------------------------------------------ + + +@dataclass(frozen=True) +class PinnedThreshold: + """One pinned constant behind a non-spine edge write. + + ``module``/``name`` + Import path and attribute, resolved by the test to reach the live + object. Private names (leading underscore) are included: the + detector's behaviour does not care about the convention, and + ``_STOPWORDS`` moves the residual-overlap score as surely as + ``DEFAULT_RESIDUAL_OVERLAP_MIN`` does. + + ``value`` + Output of :func:`pin_value` — a literal for scalars, a + ``sha256:`` digest for collections. + + ``edge_types`` + Which edge types this constant can change. Informational; the + coverage test keys on the writer set, not on this field. + + ``gates`` + What observably changes about the written edge set when the value + moves. A constant that cannot answer this does not belong here. + """ + + module: str + name: str + kind: str + value: str + size: int | None + edge_types: tuple[str, ...] + overridable: str + gates: str + + +THRESHOLDS: Final[tuple[PinnedThreshold, ...]] = ( + # --- relationship_detector: CONTRADICTS + POTENTIALLY_STALE -------- + # + # Two writers share this threshold set and partition the contradicting + # pairs by score: `write_semantic_edges` takes score >= confidence_min + # and emits CONTRADICTS, `write_potentially_stale_edges` takes the + # sub-confidence half and emits POTENTIALLY_STALE. A change to + # confidence_min therefore does not just resize one edge population, + # it moves pairs from one edge type to the other. + PinnedThreshold( + module="aelfrice.relationship_detector", + name="DEFAULT_JACCARD_MIN", + kind=KIND_CUTOFF, + value="0.4", + size=None, + edge_types=("CONTRADICTS", "POTENTIALLY_STALE"), + overridable=OVERRIDE_TOML, + gates=( + "Token-overlap floor a pair must clear to enter the " + "classifier at all. Lowering it enlarges the candidate pool " + "and can only add edges; raising it can only remove them." + ), + ), + PinnedThreshold( + module="aelfrice.relationship_detector", + name="DEFAULT_RESIDUAL_OVERLAP_MIN", + kind=KIND_CUTOFF, + value="0.4", + size=None, + edge_types=("CONTRADICTS", "POTENTIALLY_STALE"), + overridable=OVERRIDE_KWARG, + gates=( + "Residual-content overlap floor. Below it `analyze` returns " + "`unrelated` with score 0.0 regardless of modality signals, " + "so no edge of either type is written for the pair." + ), + ), + PinnedThreshold( + module="aelfrice.relationship_detector", + name="DEFAULT_CONFIDENCE_MIN", + kind=KIND_CUTOFF, + value="0.5", + size=None, + edge_types=("CONTRADICTS", "POTENTIALLY_STALE"), + overridable=OVERRIDE_TOML, + gates=( + "The split point between the two writers, not a simple " + "on/off: a contradicting pair at or above it becomes a " + "CONTRADICTS edge, below it a POTENTIALLY_STALE edge." + ), + ), + PinnedThreshold( + module="aelfrice.relationship_detector", + name="DEFAULT_MAX_CANDIDATE_PAIRS", + kind=KIND_CAP, + value="5000", + size=None, + edge_types=("CONTRADICTS", "POTENTIALLY_STALE"), + overridable=OVERRIDE_TOML, + gates=( + "Truncates the candidate-pair pool. Pairs past the cap are " + "never classified, so on a large store this silently bounds " + "which edges can exist at all." + ), + ), + PinnedThreshold( + module="aelfrice.relationship_detector", + name="DEFAULT_MAX_EDGES_PER_BELIEF", + kind=KIND_CAP, + value="8", + size=None, + edge_types=("CONTRADICTS",), + overridable=OVERRIDE_KWARG, + gates=( + "Per-belief write-gate bounding the Exp-48 coverage-dilution " + "failure mode. Pairs are processed in deterministic audit " + "order, so which edges survive the cap is deterministic too." + ), + ), + PinnedThreshold( + module="aelfrice.relationship_detector", + name="QUANT_AXIS", + kind=KIND_PATTERN_TABLE, + value="sha256:fba89a73d04492ecb3bf51ee6925fa66e40b5b524e8a735360179212c509b7ff", + size=13, + edge_types=("CONTRADICTS", "POTENTIALLY_STALE"), + overridable=OVERRIDE_NONE, + gates=( + "Quantifier positions on the frequency axis. The score is " + "half the axis distance, so moving any value moves the score " + "across confidence_min and reclassifies the pair." + ), + ), + PinnedThreshold( + module="aelfrice.relationship_detector", + name="_NEGATION_TOKENS", + kind=KIND_TOKEN_SET, + value="sha256:1bfa98c280a92274a3caf35607bf41a56e9e55da3ab0f3fdf1d57e7c8609a7d9", + size=20, + edge_types=("CONTRADICTS", "POTENTIALLY_STALE"), + overridable=OVERRIDE_NONE, + gates=( + "Drives the negation term, which is 1.0 when exactly one " + "side is negated. Adding a token can flip a pair from " + "`refines` (no edge) to `contradicts` (edge)." + ), + ), + PinnedThreshold( + module="aelfrice.relationship_detector", + name="_CONTRACTION_NEGATION_RE", + kind=KIND_PATTERN_TABLE, + value="sha256:2410353c94c8a1dcd6aa85bc838af043a7feb45f5c34e07c8f5122537b571f21", + size=None, + edge_types=("CONTRADICTS", "POTENTIALLY_STALE"), + overridable=OVERRIDE_NONE, + gates=( + "Second negation pass over raw content, catching contracted " + "forms the tokenizer splits. Same effect on the negation " + "term as the token set." + ), + ), + PinnedThreshold( + module="aelfrice.relationship_detector", + name="_STOPWORDS", + kind=KIND_TOKEN_SET, + value="sha256:b913860f62f01f806cf7bf2f18a68c4630c7feb2e082898c3a59c17d74f3890c", + size=125, + edge_types=("CONTRADICTS", "POTENTIALLY_STALE"), + overridable=OVERRIDE_NONE, + gates=( + "Subtracted before the residual-overlap check, so it sets " + "the denominator of that score. Note it is a union with the " + "negation and quantifier vocabularies — editing either of " + "those moves this digest too." + ), + ), + # --- contradiction: SUPERSEDES ------------------------------------ + PinnedThreshold( + module="aelfrice.contradiction", + name="SUPERSEDES_WEIGHT", + kind=KIND_WEIGHT, + value="1.0", + size=None, + edge_types=("SUPERSEDES",), + overridable=OVERRIDE_NONE, + gates=( + "Weight stamped on every SUPERSEDES edge. Does not change " + "which edges are written, but does change the propagation " + "arithmetic a recompute must reproduce byte-for-byte." + ), + ), + PinnedThreshold( + module="aelfrice.contradiction", + name="CLASS_NAMES", + kind=KIND_PATTERN_TABLE, + value="sha256:4a55a5bd080b914f9d77b3b7c58c85dca6eaf48a05c0ea9ff06b91a4ed365749", + size=6, + edge_types=("SUPERSEDES",), + overridable=OVERRIDE_NONE, + gates=( + "Keyed on the PRECEDENCE_* integers, so this one digest pins " + "the whole precedence ordering. `_pick_winner` compares those " + "integers to choose the winner, and the winner becomes the " + "edge's `src` — reordering them does not resize the edge set, " + "it REVERSES edges that are already there, which no count-based " + "check would notice." + ), + ), + # --- triple_extractor: the explicit-relation surface --------------- + PinnedThreshold( + module="aelfrice.triple_extractor", + name="_PATTERNS", + kind=KIND_PATTERN_TABLE, + value="sha256:db6c470a051a084c83abdc0ca03e38948ff228b77c906799271b834d164b47cf", + size=25, + edge_types=( + "SUPPORTS", "CITES", "CONTRADICTS", "SUPERSEDES", + "RELATES_TO", "DERIVED_FROM", "IMPLEMENTS", "TESTS", + ), + overridable=OVERRIDE_NONE, + gates=( + "The phrase-to-edge-type table. This is the only writer that " + "chooses among most edge types, so it decides both whether " + "an edge exists and which type it is." + ), + ), + PinnedThreshold( + module="aelfrice.triple_extractor", + name="ANCHOR_CONTEXT_TARGET", + kind=KIND_CAP, + value="80", + size=None, + edge_types=("SUPPORTS", "CITES", "RELATES_TO", "TESTS"), + overridable=OVERRIDE_NONE, + gates=( + "Target width of the anchor text stored on the edge. Changes " + "the persisted `anchor_text` column, which a recompute " + "comparing whole edge rows will read as divergence." + ), + ), + # --- value_compare: the typed-slot contradiction gate -------------- + # + # DORMANT, and pinned deliberately anyway. The gate is reached only + # from `analyze(use_value_comparison=True)`; nothing shipped passes + # that flag — it defaults False at relationship_detector.py:263 and + # :349, and `relationships_audit` does not thread it — so today these + # four constants change no edge in any store. They are recorded + # because the flag is the only thing between them and the write path: + # flipping it makes a slot conflict short-circuit the + # residual-overlap floor at a fixed score of 1.0, minting CONTRADICTS + # edges for pairs the token path calls unrelated. Pinning now means + # the flip is a one-line change against a known baseline rather than + # a change to four unpinned constants at once. The `gates` text below + # describes what happens WHEN reached; read it in that mood. + PinnedThreshold( + module="aelfrice.value_compare", + name="DEFAULT_NUMERIC_REL_TOL", + kind=KIND_CUTOFF, + value="0.01", + size=None, + edge_types=("CONTRADICTS",), + overridable=OVERRIDE_KWARG, + gates=( + "Relative tolerance below which two numbers for the same " + "slot count as agreeing. Widening it suppresses conflicts " + "and removes edges; narrowing it mints them." + ), + ), + PinnedThreshold( + module="aelfrice.value_compare", + name="ENUM_VOCAB", + kind=KIND_PATTERN_TABLE, + value="sha256:75f2af93073693b6657e7718e252c11bed07eff5a7c33bc9202d1a71a97675b1", + size=9, + edge_types=("CONTRADICTS",), + overridable=OVERRIDE_NONE, + gates=( + "Mutual-exclusion groups per category. Two beliefs landing " + "in different groups of one category is a conflict, which " + "forces `contradicts` at score 1.0." + ), + ), + PinnedThreshold( + module="aelfrice.value_compare", + name="_NUMERIC_KEY_DROP", + kind=KIND_TOKEN_SET, + value="sha256:64bbd8a03443c804b2814e92200a44b1bf4328c85cc892de39f565633bf4e4ff", + size=24, + edge_types=("CONTRADICTS",), + overridable=OVERRIDE_NONE, + gates=( + "Keys discarded before numeric slots are compared. A key " + "added here can no longer produce a conflict, so edges " + "disappear silently." + ), + ), + PinnedThreshold( + module="aelfrice.value_compare", + name="_NUMERIC_RE", + kind=KIND_PATTERN_TABLE, + value="sha256:b6152502c407f836d8ac933a84564fb08a421ae6068aa0232c68ac74c74b6dd8", + size=None, + edge_types=("CONTRADICTS",), + overridable=OVERRIDE_NONE, + gates=( + "Extracts the numeric slots that are compared at all. A " + "pattern change alters which values are even eligible for a " + "conflict." + ), + ), + # --- wonder: RELATES_TO from phantom to constituents --------------- + # + # `wonder.lifecycle` writes one RELATES_TO edge per constituent of an + # ingested phantom, so what decides the edge set is what decides + # which phantoms reach it. There are two such paths, and NEITHER is + # the bake-off: + # + # 1. `cli.py` / `mcp_server.py` rank BFS hops by + # `wonder_consolidation.score` and keep `[: --top]`. + # 2. `wonder.skill_integration` turns research-agent documents into + # phantoms over an anchor tuple, seeded by `wonder.dispatch`. + # + # `wonder.{strategies,evaluator,simulator,runner}` are deliberately + # NOT pinned. The package docstring states they are research-only and + # do not write to a live store, and `runner` — their sole importer — + # builds against `MemoryStore(":memory:")`. Their thresholds + # (JACCARD_REDUNDANCY, JUNK_RATE_DEFER, TC_EDGE_TYPES, …) are real + # knobs on the #228 bake-off and change no edge in any user's store, + # so pinning them here would pad the manifest with entries whose + # `gates` text could not be true. + PinnedThreshold( + module="aelfrice.wonder.lifecycle", + name="_CONSTITUENT_KEY_VERSION", + kind=KIND_LITERAL, + value="\"v2\"", + size=None, + edge_types=("RELATES_TO",), + overridable=OVERRIDE_NONE, + gates=( + "Prefix of the phantom idempotency key. Bumping it makes every " + "existing phantom miss its own dedup guard and re-ingest as a " + "new belief, minting a second full set of RELATES_TO edges — " + "so this string decides edge *duplication*, not just naming." + ), + ), + PinnedThreshold( + module="aelfrice.wonder.dispatch", + name="UNCERTAINTY_THRESHOLD", + kind=KIND_CUTOFF, + value="0.7", + size=None, + edge_types=("RELATES_TO",), + overridable=OVERRIDE_NONE, + gates=( + "Posterior-uncertainty floor for a belief to become a wonder " + "anchor in the `--axes` dispatch payload. Those anchors are " + "the constituent tuple `skill_integration` persists, so the " + "floor decides which RELATES_TO edges the persist-docs path " + "writes. It does NOT touch the BFS path in item 1 above." + ), + ), + PinnedThreshold( + module="aelfrice.wonder_consolidation", + name="_TOKENIZER_DROP", + kind=KIND_TOKEN_SET, + value="sha256:e5e1d1b663e88634d05ab0cd7349e2c7f0e165d0ea66d20f9aaae6d45bdb2661", + size=14, + edge_types=("RELATES_TO",), + overridable=OVERRIDE_NONE, + gates=( + "Punctuation stripped before the relatedness score that ranks " + "BFS hops. That ranking, then a `[: --top]` slice, is what " + "selects the phantoms the BFS path persists — so this set " + "reorders the candidate list and changes which RELATES_TO " + "edges survive the cut." + ), + ), + # --- shared upstream: the token universe and the anchor column ----- + # + # Neither module writes an edge, so the call-site sweep cannot see + # them; both are pinned because a change to either moves edges in + # every writer downstream of it. + PinnedThreshold( + module="aelfrice.bm25", + name="_TOKEN_PATTERN", + kind=KIND_PATTERN_TABLE, + value="sha256:0194ef069cb572f77ccace10a4d389d81c0aee13e7cb8757ff2f7155d886adbb", + size=None, + edge_types=("CONTRADICTS", "POTENTIALLY_STALE"), + overridable=OVERRIDE_NONE, + gates=( + "`tokenize` is the token universe for the Jaccard prefilter, " + "the negation and quantifier membership tests, and the " + "residual-overlap set. Widening or narrowing this one pattern " + "moves every CONTRADICTS and POTENTIALLY_STALE edge in the " + "store, which is why it outranks any single cutoff below." + ), + ), + PinnedThreshold( + module="aelfrice.models", + name="ANCHOR_TEXT_MAX_LEN", + kind=KIND_CAP, + value="1000", + size=None, + edge_types=("SUPPORTS", "CITES", "RELATES_TO", "TESTS"), + overridable=OVERRIDE_NONE, + gates=( + "`Edge.__post_init__` truncates `anchor_text` to this length " + "on construction, so it alters the persisted row of every " + "edge that carries one. Does not change which edges exist; " + "does change what a row-level recompute comparison sees." + ), + ), +) + + +# --- Digest guard ------------------------------------------------------ + +# sha256 over the whole manifest. Editing any pinned value changes this, +# which is what forces DETECTOR_THRESHOLDS_VERSION to move with it. +MANIFEST_DIGEST: Final[str] = ( + "1edce3ea3ddd950f7a81201a6fda33cae89da96332abd0f4c2d20bcfb0053c02" +) + + +def manifest_digest() -> str: + """Digest the manifest, including the version it was recorded under.""" + payload = { + "version": DETECTOR_THRESHOLDS_VERSION, + "thresholds": [_canonical(t) for t in THRESHOLDS], + } + return hashlib.sha256(_dumps(payload).encode("utf-8")).hexdigest() + + +# Every module that writes a non-TEMPORAL_NEXT, non-DERIVED_FROM edge. +# The coverage test asserts this equals the set of modules reaching +# `MemoryStore.insert_edge`, minus the documented exclusions below, so a +# new writer landing without a manifest entry fails rather than passing +# unnoticed. +COVERED_WRITER_MODULES: Final[frozenset[str]] = frozenset({ + "aelfrice.relationship_detector", + "aelfrice.contradiction", + "aelfrice.triple_extractor", + "aelfrice.wonder.lifecycle", +}) + +# Call sites of `insert_edge` that are deliberately unpinned, with the +# reason. Kept here rather than in the test so the exclusion list is part +# of the record a reviewer reads. +EXCLUDED_WRITERS: Final[tuple[tuple[str, str], ...]] = ( + ( + "aelfrice.temporal_spine", + "writes TEMPORAL_NEXT only — the spine, recomputed by #1336", + ), + ( + "aelfrice.ingest", + "writes DERIVED_FROM only; population is #1354's scope", + ), + ( + "aelfrice.derivation_worker", + "relays edges built by derive(), which returns none today " + "(#1354); it applies no threshold of its own", + ), + ( + "aelfrice.migrate", + "copies existing edge rows between stores; makes no detection " + "decision, so there is no threshold to pin", + ), + ( + "aelfrice.benchmark", + "builds a fixed synthetic multi-hop fixture for benchmarks, not " + "a detector over user beliefs", + ), + ( + "aelfrice.wonder.simulator", + "seeds the #228 synthetic bake-off corpus from a seed; not a " + "detector over user beliefs", + ), +) + + +__all__ = [ + "COVERED_WRITER_MODULES", + "DETECTOR_THRESHOLDS_VERSION", + "EXCLUDED_WRITERS", + "KINDS", + "MANIFEST_DIGEST", + "PinnedThreshold", + "THRESHOLDS", + "manifest_digest", + "pin_value", + "size_of", +] From 5186491869bc763a31995cc776c9f7c259ce98ac Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:33:50 -0700 Subject: [PATCH 02/15] test(detectors): assert the manifest against live source, and sweep for unpinned writers Three independent failure modes, three arms that fail independently. Arm 1 re-derives each pinned value from the live constant by importing it, so the comparison is manifest-literal against source rather than symbol against itself. This is the defect the issue was filed on: test_config_loader_overrides_and_falls_back asserts cfg.jaccard_min == DEFAULT_JACCARD_MIN, and changing the constant from 0.4 to 0.9 leaves it green -- measured, not asserted. Arm 2 pins a digest taken over the manifest INCLUDING its version, which is what makes "changing a value forces a version bump" mechanical rather than conventional: a value edit fails twice, and the only route back to green moves MANIFEST_DIGEST next to the version. Arm 3 sweeps insert_edge call sites out of source text rather than comparing against a hand-list, so a writer landing later fails here instead of sitting silently outside the manifest. A companion arm asserts only the store writes the edges table directly, so the sweep cannot be evaded with raw SQL. Covered-ness matches on the exact module, not a package prefix -- prefix matching is what let wonder.lifecycle look covered by wonder.evaluator, a research-only module that writes to no live store. Verified by mutation, eight cases: five source-side (a cutoff, a token added to a negation set -- red twice, since the stopword set is a union of it -- dropping re.IGNORECASE from a triple pattern, widening the bm25 token pattern, and shortening the anchor cap) and three manifest-side (a version bump alone, relaxing a pinned value to match a drift, and a comment-only edit that correctly stays green because the digest covers data rather than source text). --- .../test_detector_thresholds_manifest_1355.py | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/test_detector_thresholds_manifest_1355.py diff --git a/tests/test_detector_thresholds_manifest_1355.py b/tests/test_detector_thresholds_manifest_1355.py new file mode 100644 index 000000000..0a4a79316 --- /dev/null +++ b/tests/test_detector_thresholds_manifest_1355.py @@ -0,0 +1,243 @@ +"""The #1355 threshold manifest must track live source, and cover every writer. + +Three independent failure modes, three independent arms: + +1. **A constant drifts.** Someone edits ``DEFAULT_JACCARD_MIN`` and the + manifest still records the old value. + → ``test_pinned_value_matches_live_source``. + +2. **The manifest is edited without a version bump.** Someone updates the + manifest to match their new constant and ships, leaving + ``DETECTOR_THRESHOLDS_VERSION`` at 1 — so two different edge-producing + behaviours claim the same version. + → ``test_manifest_digest_is_pinned``. + +3. **A new writer lands unpinned.** Someone adds a module that writes a + non-spine edge and never touches the manifest. + → ``test_every_edge_writer_is_classified``. + +The arms are deliberately not collapsible into one. #1353's lesson, and the +defect this issue was filed on, is the same: a test coupled to the *symbol* +rather than to the shipped *value* survives any change to what the symbol +resolves to. ``tests/test_relationship_detector.py`` asserts +``cfg.jaccard_min == DEFAULT_JACCARD_MIN``, which compares the constant to +itself and would stay green if the value became 0.9. +""" +from __future__ import annotations + +import importlib +import re +from pathlib import Path + +import pytest + +import aelfrice +from aelfrice.detector_thresholds import ( + COVERED_WRITER_MODULES, + DETECTOR_THRESHOLDS_VERSION, + EXCLUDED_WRITERS, + KINDS, + MANIFEST_DIGEST, + PinnedThreshold, + THRESHOLDS, + manifest_digest, + pin_value, + size_of, +) + +_PKG_ROOT = Path(aelfrice.__file__).resolve().parent + +# Only real call sites: a leading dot means it is invoked on a store +# instance. The prose references in store.py and retrieval.py name the +# method without one, so they are not swept up. +_CALL_RE = re.compile(r"\.insert_edge\s*\(") +# A writer that reaches the table directly would bypass the call-site +# sweep entirely, so it is checked separately. +_RAW_SQL_RE = re.compile(r"INSERT\s+(?:OR\s+\w+\s+)?INTO\s+edges", re.IGNORECASE) + + +def _module_name(path: Path) -> str: + rel = path.relative_to(_PKG_ROOT).with_suffix("") + return "aelfrice." + ".".join(rel.parts) + + +def _source_files() -> list[Path]: + return sorted(p for p in _PKG_ROOT.rglob("*.py") if "__pycache__" not in p.parts) + + +def _edge_writer_modules() -> set[str]: + """Every module calling ``insert_edge`` on a store, from source text.""" + found: set[str] = set() + for path in _source_files(): + if _CALL_RE.search(path.read_text(encoding="utf-8")): + found.add(_module_name(path)) + return found + + +# --- Arm 1: the manifest tracks live source --------------------------- + + +@pytest.mark.parametrize( + "entry", THRESHOLDS, ids=[f"{t.module}.{t.name}" for t in THRESHOLDS], +) +def test_pinned_value_matches_live_source(entry: PinnedThreshold) -> None: + """Every pinned value is re-derived from the live constant, not asserted + against itself. + + The manifest holds hand-written literals and imports nothing from + ``aelfrice``; this test does the importing. That asymmetry is the whole + point — if the manifest imported the constants it describes, this + comparison would be ``x == x``. + + Verified by mutation: changing ``DEFAULT_JACCARD_MIN`` from 0.4 to 0.9 + in source turns this arm red. It leaves arm 2 green — the digest covers + the manifest, not live source, and the two arms are meant to catch + different things. The same mutation leaves + ``test_config_loader_overrides_and_falls_back`` green, which is the + defect this file exists to close. + """ + live = getattr(importlib.import_module(entry.module), entry.name) + assert pin_value(live) == entry.value, ( + f"{entry.module}.{entry.name} moved: manifest records {entry.value}, " + f"source now yields {pin_value(live)}. Update the manifest AND bump " + f"DETECTOR_THRESHOLDS_VERSION." + ) + assert size_of(live) == entry.size, ( + f"{entry.module}.{entry.name} changed size: manifest records " + f"{entry.size}, source has {size_of(live)}." + ) + + +def test_scalar_entries_pin_a_literal_not_a_digest() -> None: + """A scalar must stay readable as its literal. + + Guards the lazy repair path: a digest satisfies arm 1 just as well as a + literal, so a failing scalar could be "fixed" by converting it to + ``sha256:...`` — which would technically pass while destroying the + reviewability the issue asked for ("a test asserting the literal + shipped values"). + """ + for entry in THRESHOLDS: + if entry.size is None and entry.kind in {"numeric_cutoff", "cap", "weight", "literal"}: + assert not entry.value.startswith("sha256:"), ( + f"{entry.module}.{entry.name} is a scalar and must pin its " + f"literal, not a digest" + ) + + +def test_entries_are_wellformed_and_unique() -> None: + seen: set[tuple[str, str]] = set() + for entry in THRESHOLDS: + key = (entry.module, entry.name) + assert key not in seen, f"duplicate manifest entry: {key}" + seen.add(key) + assert entry.kind in KINDS, f"{key}: unknown kind {entry.kind!r}" + assert entry.edge_types, f"{key}: must name the edge types it affects" + assert entry.gates.strip(), f"{key}: must say what it gates" + + +# --- Arm 2: editing the manifest forces a version bump ---------------- + + +def test_manifest_digest_is_pinned() -> None: + """The digest covers the version, so the two move together. + + This is what makes "changing a value forces a version bump" mechanical + rather than a convention. Editing a pinned value changes + ``manifest_digest()``; the only way back to green is to update + ``MANIFEST_DIGEST``, and a reviewer seeing that line move knows to look + for the version bump beside it. + + Verified by mutation, both directions: bumping + ``DETECTOR_THRESHOLDS_VERSION`` alone turns this red, and editing any + pinned value alone turns this red. + """ + assert manifest_digest() == MANIFEST_DIGEST, ( + "manifest content or version changed without updating " + "MANIFEST_DIGEST" + ) + + +def test_version_is_a_positive_int() -> None: + assert isinstance(DETECTOR_THRESHOLDS_VERSION, int) + assert DETECTOR_THRESHOLDS_VERSION >= 1 + + +# --- Arm 3: every writer is classified -------------------------------- + + +def test_every_edge_writer_is_classified() -> None: + """No module may write an edge without being either covered or excluded. + + Swept from source text rather than from a hand-list, so a new writer + landing tomorrow fails here instead of silently sitting outside the + manifest. This is the acceptance criterion the issue puts last and the + easiest one to under-deliver: pinning ``relationship_detector`` alone + would look complete while leaving the explicit-relation surface + (``triple_extractor``) and the wonder lane unpinned. + + A writer belongs in ``EXCLUDED_WRITERS`` only with a stated reason — + it writes a spine or ``DERIVED_FROM`` edge, it relays edges decided + elsewhere, or it is a fixture builder rather than a detector. + """ + excluded = {m for m, _ in EXCLUDED_WRITERS} + classified = COVERED_WRITER_MODULES | excluded + actual = _edge_writer_modules() + + unclassified = actual - classified + assert not unclassified, ( + f"these modules call insert_edge but are neither pinned in " + f"COVERED_WRITER_MODULES nor listed in EXCLUDED_WRITERS: " + f"{sorted(unclassified)}" + ) + + vanished = classified - actual + assert not vanished, ( + f"these modules are classified but no longer call insert_edge — " + f"drop them from the manifest: {sorted(vanished)}" + ) + + +def test_covered_modules_all_have_entries() -> None: + """A module in the covered set must actually contribute a threshold. + + Match is on the exact module, not a package prefix. Prefix matching is + what let ``wonder.lifecycle`` look covered because ``wonder.evaluator`` + had entries — and ``evaluator`` is research-only, imported solely by + the bake-off runner against an in-memory store, so it decides no edge + in any user's store. Exact matching makes that substitution impossible. + """ + pinned = {t.module for t in THRESHOLDS} + for module in COVERED_WRITER_MODULES: + assert module in pinned, ( + f"{module} is in COVERED_WRITER_MODULES but no manifest entry " + f"names it. Matching by package prefix instead would let a " + f"sibling module's entry stand in for it — which is how " + f"wonder.lifecycle first looked covered by wonder.evaluator, a " + f"research-only module that writes to no live store." + ) + + +def test_covered_and_excluded_do_not_overlap() -> None: + excluded = {m for m, _ in EXCLUDED_WRITERS} + assert not (COVERED_WRITER_MODULES & excluded) + for module, reason in EXCLUDED_WRITERS: + assert reason.strip(), f"{module} excluded without a reason" + + +def test_only_the_store_writes_the_edges_table_directly() -> None: + """Raw SQL would bypass the call-site sweep arm 3 depends on. + + ``insert_edge`` is also where the #1254 ownership gate lives, so a + module reaching the table directly would evade that too. Keeping the + check here means arm 3 cannot be quietly defeated. + """ + offenders = [ + _module_name(p) + for p in _source_files() + if _RAW_SQL_RE.search(p.read_text(encoding="utf-8")) + ] + assert offenders == ["aelfrice.store"], ( + f"only the store may write the edges table directly; found " + f"{offenders}" + ) From af0cd6526ba7a596b7eeb5127f8809503f3c6414 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:33:50 -0700 Subject: [PATCH 03/15] docs(changelog): record the detector-threshold manifest (#1355) --- CHANGELOG/v4.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG/v4.md b/CHANGELOG/v4.md index d58eda010..6f0c5c889 100644 --- a/CHANGELOG/v4.md +++ b/CHANGELOG/v4.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`ingest_log.derived_edge_ids` now populates, forward-only, and an edge-set divergence is drift ([#1354](https://github.com/robotrocketscience/aelfrice/issues/1354), [#1157](https://github.com/robotrocketscience/aelfrice/issues/1157) AC3/AC4).** The column was NULL on **every** row (0 of 140,251 on the development store) because all six `derive()` return paths hardcoded `edges=[]`, so no edge had a log row pointing at its origin. `derive()` now emits `DERIVED_FROM` from a reserved `raw_meta.derived_from` block carrying the preceding sentence's verbatim text, and the derivation worker stamps what it emitted. The input travels in `raw_meta` rather than as a `DerivationInput` field because `raw_meta` is already passed through verbatim by both reconstructors — the worker and the replay harness — so it cannot drift between them; text rather than an id keeps `_belief_id` the single minter. **Forward-only with no migration and no new column:** `[]` now means "derived none" and SQL `NULL` means "no edge-aware writer ever saw this row", and since `list_unstamped_ingest_log` filters on `derived_belief_ids IS NULL`, no pass ever revisits a stamped row. The replay probe's edge comparison moves out of the informational `mutable_divergence` bucket into its own `edge_set_divergence` counter that **does** trigger `has_drift` — guarded on the raw column being non-NULL, not on the decoded set, because `_logged_edge_set` collapses NULL, `'[]'` and corruption to the same empty set and a guard written against its output would exempt corruption along with history. **Two corrections to the issue's own premise, both measured.** `DERIVED_FROM` is not emittable without new inputs: neither production writer's target is on the log row — the intra-turn one resolves it from the worker's outcomes map *after* derivation, the inter-turn one from an in-memory per-session dict. And the reachable share is much smaller than the `8.9%` the issue scoped from: only the intra-turn writer is liftable, and attributing the live set by endpoint timestamp puts it at **at most 918 of 47,533 edges (1.93%)** — 21.7% of `DERIVED_FROM`, not all of it. The inter-turn writer's `src` is the turn's *last newly-inserted* belief, which is not knowable at `record_ingest` time. Also fixes a latent crash this would otherwise have shipped: the worker's edge loop was a bare `INSERT` into a table keyed `PRIMARY KEY (src, dst, type)` with no per-row `try/except`, so the first `derive()` to emit an edge turned re-ingest — the documented idempotency contract — into an `IntegrityError`. +- **The thresholds behind the 2.8% non-spine edges are pinned and versioned, so "edges are recomputable" is now checkable for them ([#1355](https://github.com/robotrocketscience/aelfrice/issues/1355), [#1283](https://github.com/robotrocketscience/aelfrice/issues/1283) AC2).** #1283 restated AC2 in two halves; the recompute half shipped, and this is the other one. Edges that are neither `TEMPORAL_NEXT` nor `DERIVED_FROM` are a function of the belief set **and** of detector thresholds, so recomputability holds only if those thresholds are pinned — and they were bare module constants with no guard at all. `aelfrice.detector_thresholds` records **22 constants across 8 modules** behind `DETECTOR_THRESHOLDS_VERSION = 1`: the five `relationship_detector` cutoffs and caps plus its quantifier axis and three token/pattern tables, `contradiction.SUPERSEDES_WEIGHT` and its precedence ladder, the `triple_extractor` phrase-to-edge-type table and anchor width, four `value_compare` slot-gate constants, and the constants on the two paths that actually decide which phantoms reach the `RELATES_TO` writer. **The defect being fixed was a test that could not fail.** `tests/test_relationship_detector.py` asserted `cfg.jaccard_min == DEFAULT_JACCARD_MIN`, comparing the constant to itself; changing `DEFAULT_JACCARD_MIN` from 0.4 to **0.9** in source leaves that test **green**, which is measured here rather than asserted — the same defect class as the #1353 synth-exclusion pin. The manifest holds hand-written literals and **imports nothing from `aelfrice`**, precisely so it cannot repeat that mistake: the test does the importing and re-derives each pinned form from the live object, and scalars must stay literals (a separate arm rejects "repairing" a red scalar by converting it to a digest). Collections pin as a digest of a canonical form that **includes regex flags** — dropping `re.IGNORECASE` from the triple patterns changes no pattern text but changes which triples match, and it goes red. A digest over the whole manifest **includes the version**, which is what makes "changing a value forces a version bump" mechanical: editing a pinned value fails twice, once on the live-source mismatch and once on the digest, and a comment-only edit stays green because the digest is over data, not source text. Coverage is **swept from source, not hand-listed** — every module calling `insert_edge` must be either pinned or excluded with a stated reason (**4 covered, 6 excluded**: the spine, the two `DERIVED_FROM` paths, the cross-store copier, and two synthetic-fixture builders), and a separate arm asserts only the store writes the `edges` table directly, so the sweep cannot be evaded with raw SQL. **Reachability was checked, not assumed, and it moved two things.** A first pass pinned six `wonder.{evaluator,strategies}` constants as the gate on `RELATES_TO`; the package docstring says outright that those strategies are research-only and do not write to a live store, and their sole importer builds against `MemoryStore(":memory:")`, so they decide no edge in any user's store and were dropped rather than shipped with `gates` text that could not be true. The paths that *do* decide it — BFS hops ranked by `wonder_consolidation.score` then sliced by `--top`, and the dispatch-seeded persist-docs path — are pinned instead. The four `value_compare` entries are kept but relabelled **dormant**: no shipped caller passes `use_value_comparison=True`, so the slot gate mints nothing today, and pinning it now makes flipping that flag a one-line change against a known baseline. Relatedly, the coverage sweep can only see modules that *call* `insert_edge`, so three upstream suppliers were added by hand — `bm25._TOKEN_PATTERN` (the token universe for every Jaccard and membership test in the detector), `models.ANCHOR_TEXT_MAX_LEN`, and `wonder_consolidation._TOKENIZER_DROP` — and the two known-unpinned suppliers (`dedup`'s prefilter semantics, `config_discovery`'s file resolution) are named in the module docstring so their absence is a recorded decision. + +**Two limits stated rather than papered over.** Three of the entries are defaults a `.aelfrice.toml` `[relationship_detector]` section can override, so for those the manifest pins the *shipped default*, not the value a given store actually ran with; every entry names its override mechanism so the two are distinguishable. And this is **forward-only** — the `edges` table has no version and no `created_at`, so pinning today does not make a historical edge attributable to the thresholds that produced it. Adding those columns is the `edges`-table migration that left stores unopenable-forever in #1161, and historical reproduction stays explicitly out of scope. - **The injected block can now carry the evidence behind each belief, grouped by trust tier ([#1326](https://github.com/robotrocketscience/aelfrice/issues/1326), [#1177](https://github.com/robotrocketscience/aelfrice/issues/1177) proposal 18).** The per-turn line rendered `id`, `lock` and (since #1171) `speculative`, and threw away everything else the store knows about how far to trust a belief. `[hook] provenance_render` (default-**off**, `AELFRICE_PROVENANCE_RENDER` overrides) groups the block into `` / `` / `` with a framing clause each, and emits `origin`, `n` (= `alpha + beta`), `mu` and `seen` on non-locked lines. Every value is already on the belief at render time — measured, all four populated on **74 of 74** hits in a live pack — so there is no new query. The point is `n`: `mu = 0.6 at n = 2` is byte-identical to `mu = 0.6 at n = 200` at every scoring site, and one live pack carried **25 distinct `n` values from 1.6 to 363.2** inside a single turn's block, so the signal the ranker must collapse is one the model can weigh contextually. Section membership is a **total** function of `lock_level` and `origin` — the proposal as filed classified origins with two literal sets that between them stranded **6,396 active beliefs (14.3%)** in no section at all, and named two origins (`commit`, `file`) that do not exist; a renderer written to it would have dropped 14.3% of the block with no error. Every `models.ORIGIN_*` constant is now classified, an unrecognised origin falls back to `` rather than vanishing, and a test enumerates the constants from `models` so a new origin cannot be added without being classified. `speculative="1"` is folded into `origin="speculative"` rather than emitted alongside it, while the #1171 framing sentence still fires. With the flag off the block is byte-identical to before, asserted against literal expected bytes rather than recomputed. - **The temporal spine is recomputable from the log, and the gap is named rather than averaged ([#1283](https://github.com/robotrocketscience/aelfrice/issues/1283)).** `aelf spine verify` recomputes the `TEMPORAL_NEXT` set on the ratified key — `(created_at, ingest_log ULID)` — and reports the divergence against what shipped. **This is the recompute half only; the writer still orders by `(created_at, rowid)`, so the number is a gap against the contract, not drift**, and the command says so in its own output because "93.68%" alone reads as decay. `rowid` is exactly the problem: it is implicit and `VACUUM` may renumber it, which is why the ratified key is the log's ULID rather than anything read off the belief table. **Three rules, none a heuristic.** Synth log rows are excluded by `source_kind = 'legacy_unknown'` — a stated durable column, exact in both directions here — because the #263 synthesis minted 20,852 rows in a 201 ms window whose order is `beliefs.rowid` relabelled, and honouring them would launder rowid order into the key the contract calls durable. **The rule is a forward safeguard, and its measured effect on this store is zero** — neutralising the exclusion so synth rows do supply keys leaves the report identical in every bucket, with the recomputed edge sets differing by 0 and the no-log sets by 0. 20,852 beliefs take a different sort key under that arm, but only **5** are session-scoped and each of those also carries a non-synth row, so nothing moves. "Exact in both directions" is a property of the column, not a measured effect, and the effect is worth naming as inert here before it is read as the reason the rule earns its place: it earns it against a store whose migration ran at a different time, which this one did not. Two heuristic detectors were measured first and both failed loudly: a prefix-versus-`ts` disagreement threshold catches legitimately delayed derivation, flags 30,738 rows and collapses reproduction to 34.75%; excluding the largest ULID-prefix date cluster would drop **51.8% of the log**, because that cluster is a real bulk backfill carrying 20,095 session-scoped beliefs, not a synth event (`benchmarks/ingest_log_ulid_clusters.py`). A belief takes its **earliest** qualifying log row, since later rows are corroborations and only the first records insertion. Beliefs with no qualifying row sort last within their `created_at` group — **a forward convention, explicitly not a recovery**: 2,426 such beliefs carry only 433 distinct timestamps, so 94% sit inside a tie where the only other durable column is a content-addressed id, and three placement rules were measured with one link of spread between them. That ordering is **unreconstructible**, and saying so is the deliverable rather than a caveat. Divergence is reported in **three buckets, never one percentage**, because only one is a defect anyone can fix and a single number lets the unreconstructible bucket mask a real key disagreement: on the development store, 41,929 shipped and 39,280 reproduced (93.68%), missing 2,100 no-log / 546 fan-in / **3 other**. Those figures reproduce the issue's gate measurement and its correction to the digit from an independent implementation. The recompute is read-only and opened read-only (#1328); 0.86 s on a 44,594-belief store. From 085120288c29942bc44e0c78dea94ee6d98391c3 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:48:23 -0700 Subject: [PATCH 04/15] fix(detectors): pin booleans as literals so size_of and pin_value agree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pin_value` excluded bool from its scalar branch and digested it, while `size_of` reports None (scalar) for a bool. A pinned boolean constant would therefore have carried `size=None` and a `sha256:` value at once — exactly the combination `test_scalar_entries_pin_a_literal_not_a_digest` rejects — so no boolean could be added to the manifest without a spurious failure. The exclusion bought nothing: json renders True as `true` and 1 as `1`, so the two were never ambiguous. Asserted directly, since no boolean is pinned today. --- src/aelfrice/detector_thresholds.py | 12 +++++++- .../test_detector_thresholds_manifest_1355.py | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/aelfrice/detector_thresholds.py b/src/aelfrice/detector_thresholds.py index 1cc0e5942..2f1eb08a2 100644 --- a/src/aelfrice/detector_thresholds.py +++ b/src/aelfrice/detector_thresholds.py @@ -154,8 +154,18 @@ def pin_value(obj: Any) -> str: pin as a digest of their canonical form — a stopword set is not reviewable inline, but its digest changes the moment a token is added or removed, which is the property the manifest needs. + + ``bool`` counts as a scalar here, and deliberately so. It is not + ambiguous with ``int`` — ``json.dumps`` renders ``True`` as ``true`` + and ``1`` as ``1`` — so digesting it buys no discrimination and costs + readability. Excluding it also put this function at odds with + :func:`size_of`, which reports ``None`` (scalar) for a bool: a pinned + boolean constant would have carried ``size=None`` and a ``sha256:`` + value at once, which is precisely the combination + ``test_scalar_entries_pin_a_literal_not_a_digest`` rejects. No boolean + is pinned today; the point is that one now can be. """ - if isinstance(obj, bool) or not isinstance(obj, (int, float, str)): + if not isinstance(obj, (int, float, str)): return "sha256:" + hashlib.sha256( _dumps(_canonical(obj)).encode("utf-8") ).hexdigest() diff --git a/tests/test_detector_thresholds_manifest_1355.py b/tests/test_detector_thresholds_manifest_1355.py index 0a4a79316..8fcae2fff 100644 --- a/tests/test_detector_thresholds_manifest_1355.py +++ b/tests/test_detector_thresholds_manifest_1355.py @@ -125,6 +125,35 @@ def test_scalar_entries_pin_a_literal_not_a_digest() -> None: ) +def test_pin_value_and_size_of_agree_on_what_a_scalar_is() -> None: + """The two functions must not disagree, or a whole type becomes unpinnable. + + ``test_scalar_entries_pin_a_literal_not_a_digest`` above selects scalars + by ``entry.size is None`` — i.e. by :func:`size_of` — and then demands + :func:`pin_value` produced a literal. So the two have to classify the + same values the same way. ``bool`` is the case where they can drift + apart: it is the one type that is both a scalar and an ``int`` subclass, + and excluding it from ``pin_value``'s scalar branch (as the first + revision of this module did) made a hypothetical pinned boolean carry + ``size=None`` and a ``sha256:`` value simultaneously — the exact pair + that guard rejects. Digesting it bought nothing either: ``true`` and + ``1`` are already distinct JSON. + + No boolean is pinned today, which is why this is asserted directly + rather than left to the manifest to demonstrate. + """ + for scalar in (True, False, 0, 1, 0.4, "v2"): + assert size_of(scalar) is None, f"size_of says {scalar!r} is not a scalar" + assert not pin_value(scalar).startswith("sha256:"), ( + f"pin_value digested {scalar!r}, but size_of classifies it as a " + f"scalar — a manifest entry for it would fail " + f"test_scalar_entries_pin_a_literal_not_a_digest with nothing wrong" + ) + + assert pin_value(True) == "true" + assert pin_value(1) == "1", "bools and ints must still pin distinguishably" + + def test_entries_are_wellformed_and_unique() -> None: seen: set[tuple[str, str]] = set() for entry in THRESHOLDS: From cb6bb3a7d579d9ed045a6701b24a93bb5b3a373a Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:48:24 -0700 Subject: [PATCH 05/15] refactor(detectors): defer the annotation-only Sequence import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Sequence` is referenced only inside a string-literal `cast`, so the name is never evaluated at runtime and the unconditional import was dead — CodeQL flagged it. Moving it under TYPE_CHECKING keeps pyright resolving the cast while removing the runtime import. --- src/aelfrice/detector_thresholds.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/aelfrice/detector_thresholds.py b/src/aelfrice/detector_thresholds.py index 2f1eb08a2..ad70c93af 100644 --- a/src/aelfrice/detector_thresholds.py +++ b/src/aelfrice/detector_thresholds.py @@ -75,9 +75,13 @@ import hashlib import json import re -from collections.abc import Sequence from dataclasses import dataclass, fields, is_dataclass -from typing import Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast + +if TYPE_CHECKING: # pragma: no cover - typing only + # Used only inside a string-literal `cast`, so the name is never + # evaluated at runtime and importing it unconditionally reads as dead. + from collections.abc import Sequence # Bump when any pinned value below changes. The digest guard in # tests/test_detector_thresholds_manifest_1355.py makes this mechanical: From 133107834cd4361b65efcac9d4a03bd30c4ac088 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:48:24 -0700 Subject: [PATCH 06/15] docs(changelog): indent the #1355 limits paragraph into its own bullet The paragraph sat unindented between two list items, which terminates the list in Markdown: it rendered as a standalone paragraph belonging to `### Added` rather than to its entry, and restarted the list below it. It is the only such paragraph in the five changelog files. Two spaces makes it a continuation of the bullet it belongs to; no text changed. --- CHANGELOG/v4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG/v4.md b/CHANGELOG/v4.md index 6f0c5c889..cbed76aa0 100644 --- a/CHANGELOG/v4.md +++ b/CHANGELOG/v4.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`ingest_log.derived_edge_ids` now populates, forward-only, and an edge-set divergence is drift ([#1354](https://github.com/robotrocketscience/aelfrice/issues/1354), [#1157](https://github.com/robotrocketscience/aelfrice/issues/1157) AC3/AC4).** The column was NULL on **every** row (0 of 140,251 on the development store) because all six `derive()` return paths hardcoded `edges=[]`, so no edge had a log row pointing at its origin. `derive()` now emits `DERIVED_FROM` from a reserved `raw_meta.derived_from` block carrying the preceding sentence's verbatim text, and the derivation worker stamps what it emitted. The input travels in `raw_meta` rather than as a `DerivationInput` field because `raw_meta` is already passed through verbatim by both reconstructors — the worker and the replay harness — so it cannot drift between them; text rather than an id keeps `_belief_id` the single minter. **Forward-only with no migration and no new column:** `[]` now means "derived none" and SQL `NULL` means "no edge-aware writer ever saw this row", and since `list_unstamped_ingest_log` filters on `derived_belief_ids IS NULL`, no pass ever revisits a stamped row. The replay probe's edge comparison moves out of the informational `mutable_divergence` bucket into its own `edge_set_divergence` counter that **does** trigger `has_drift` — guarded on the raw column being non-NULL, not on the decoded set, because `_logged_edge_set` collapses NULL, `'[]'` and corruption to the same empty set and a guard written against its output would exempt corruption along with history. **Two corrections to the issue's own premise, both measured.** `DERIVED_FROM` is not emittable without new inputs: neither production writer's target is on the log row — the intra-turn one resolves it from the worker's outcomes map *after* derivation, the inter-turn one from an in-memory per-session dict. And the reachable share is much smaller than the `8.9%` the issue scoped from: only the intra-turn writer is liftable, and attributing the live set by endpoint timestamp puts it at **at most 918 of 47,533 edges (1.93%)** — 21.7% of `DERIVED_FROM`, not all of it. The inter-turn writer's `src` is the turn's *last newly-inserted* belief, which is not knowable at `record_ingest` time. Also fixes a latent crash this would otherwise have shipped: the worker's edge loop was a bare `INSERT` into a table keyed `PRIMARY KEY (src, dst, type)` with no per-row `try/except`, so the first `derive()` to emit an edge turned re-ingest — the documented idempotency contract — into an `IntegrityError`. - **The thresholds behind the 2.8% non-spine edges are pinned and versioned, so "edges are recomputable" is now checkable for them ([#1355](https://github.com/robotrocketscience/aelfrice/issues/1355), [#1283](https://github.com/robotrocketscience/aelfrice/issues/1283) AC2).** #1283 restated AC2 in two halves; the recompute half shipped, and this is the other one. Edges that are neither `TEMPORAL_NEXT` nor `DERIVED_FROM` are a function of the belief set **and** of detector thresholds, so recomputability holds only if those thresholds are pinned — and they were bare module constants with no guard at all. `aelfrice.detector_thresholds` records **22 constants across 8 modules** behind `DETECTOR_THRESHOLDS_VERSION = 1`: the five `relationship_detector` cutoffs and caps plus its quantifier axis and three token/pattern tables, `contradiction.SUPERSEDES_WEIGHT` and its precedence ladder, the `triple_extractor` phrase-to-edge-type table and anchor width, four `value_compare` slot-gate constants, and the constants on the two paths that actually decide which phantoms reach the `RELATES_TO` writer. **The defect being fixed was a test that could not fail.** `tests/test_relationship_detector.py` asserted `cfg.jaccard_min == DEFAULT_JACCARD_MIN`, comparing the constant to itself; changing `DEFAULT_JACCARD_MIN` from 0.4 to **0.9** in source leaves that test **green**, which is measured here rather than asserted — the same defect class as the #1353 synth-exclusion pin. The manifest holds hand-written literals and **imports nothing from `aelfrice`**, precisely so it cannot repeat that mistake: the test does the importing and re-derives each pinned form from the live object, and scalars must stay literals (a separate arm rejects "repairing" a red scalar by converting it to a digest). Collections pin as a digest of a canonical form that **includes regex flags** — dropping `re.IGNORECASE` from the triple patterns changes no pattern text but changes which triples match, and it goes red. A digest over the whole manifest **includes the version**, which is what makes "changing a value forces a version bump" mechanical: editing a pinned value fails twice, once on the live-source mismatch and once on the digest, and a comment-only edit stays green because the digest is over data, not source text. Coverage is **swept from source, not hand-listed** — every module calling `insert_edge` must be either pinned or excluded with a stated reason (**4 covered, 6 excluded**: the spine, the two `DERIVED_FROM` paths, the cross-store copier, and two synthetic-fixture builders), and a separate arm asserts only the store writes the `edges` table directly, so the sweep cannot be evaded with raw SQL. **Reachability was checked, not assumed, and it moved two things.** A first pass pinned six `wonder.{evaluator,strategies}` constants as the gate on `RELATES_TO`; the package docstring says outright that those strategies are research-only and do not write to a live store, and their sole importer builds against `MemoryStore(":memory:")`, so they decide no edge in any user's store and were dropped rather than shipped with `gates` text that could not be true. The paths that *do* decide it — BFS hops ranked by `wonder_consolidation.score` then sliced by `--top`, and the dispatch-seeded persist-docs path — are pinned instead. The four `value_compare` entries are kept but relabelled **dormant**: no shipped caller passes `use_value_comparison=True`, so the slot gate mints nothing today, and pinning it now makes flipping that flag a one-line change against a known baseline. Relatedly, the coverage sweep can only see modules that *call* `insert_edge`, so three upstream suppliers were added by hand — `bm25._TOKEN_PATTERN` (the token universe for every Jaccard and membership test in the detector), `models.ANCHOR_TEXT_MAX_LEN`, and `wonder_consolidation._TOKENIZER_DROP` — and the two known-unpinned suppliers (`dedup`'s prefilter semantics, `config_discovery`'s file resolution) are named in the module docstring so their absence is a recorded decision. -**Two limits stated rather than papered over.** Three of the entries are defaults a `.aelfrice.toml` `[relationship_detector]` section can override, so for those the manifest pins the *shipped default*, not the value a given store actually ran with; every entry names its override mechanism so the two are distinguishable. And this is **forward-only** — the `edges` table has no version and no `created_at`, so pinning today does not make a historical edge attributable to the thresholds that produced it. Adding those columns is the `edges`-table migration that left stores unopenable-forever in #1161, and historical reproduction stays explicitly out of scope. + **Two limits stated rather than papered over.** Three of the entries are defaults a `.aelfrice.toml` `[relationship_detector]` section can override, so for those the manifest pins the *shipped default*, not the value a given store actually ran with; every entry names its override mechanism so the two are distinguishable. And this is **forward-only** — the `edges` table has no version and no `created_at`, so pinning today does not make a historical edge attributable to the thresholds that produced it. Adding those columns is the `edges`-table migration that left stores unopenable-forever in #1161, and historical reproduction stays explicitly out of scope. - **The injected block can now carry the evidence behind each belief, grouped by trust tier ([#1326](https://github.com/robotrocketscience/aelfrice/issues/1326), [#1177](https://github.com/robotrocketscience/aelfrice/issues/1177) proposal 18).** The per-turn line rendered `id`, `lock` and (since #1171) `speculative`, and threw away everything else the store knows about how far to trust a belief. `[hook] provenance_render` (default-**off**, `AELFRICE_PROVENANCE_RENDER` overrides) groups the block into `` / `` / `` with a framing clause each, and emits `origin`, `n` (= `alpha + beta`), `mu` and `seen` on non-locked lines. Every value is already on the belief at render time — measured, all four populated on **74 of 74** hits in a live pack — so there is no new query. The point is `n`: `mu = 0.6 at n = 2` is byte-identical to `mu = 0.6 at n = 200` at every scoring site, and one live pack carried **25 distinct `n` values from 1.6 to 363.2** inside a single turn's block, so the signal the ranker must collapse is one the model can weigh contextually. Section membership is a **total** function of `lock_level` and `origin` — the proposal as filed classified origins with two literal sets that between them stranded **6,396 active beliefs (14.3%)** in no section at all, and named two origins (`commit`, `file`) that do not exist; a renderer written to it would have dropped 14.3% of the block with no error. Every `models.ORIGIN_*` constant is now classified, an unrecognised origin falls back to `` rather than vanishing, and a test enumerates the constants from `models` so a new origin cannot be added without being classified. `speculative="1"` is folded into `origin="speculative"` rather than emitted alongside it, while the #1171 framing sentence still fires. With the flag off the block is byte-identical to before, asserted against literal expected bytes rather than recomputed. - **The temporal spine is recomputable from the log, and the gap is named rather than averaged ([#1283](https://github.com/robotrocketscience/aelfrice/issues/1283)).** `aelf spine verify` recomputes the `TEMPORAL_NEXT` set on the ratified key — `(created_at, ingest_log ULID)` — and reports the divergence against what shipped. **This is the recompute half only; the writer still orders by `(created_at, rowid)`, so the number is a gap against the contract, not drift**, and the command says so in its own output because "93.68%" alone reads as decay. `rowid` is exactly the problem: it is implicit and `VACUUM` may renumber it, which is why the ratified key is the log's ULID rather than anything read off the belief table. **Three rules, none a heuristic.** Synth log rows are excluded by `source_kind = 'legacy_unknown'` — a stated durable column, exact in both directions here — because the #263 synthesis minted 20,852 rows in a 201 ms window whose order is `beliefs.rowid` relabelled, and honouring them would launder rowid order into the key the contract calls durable. **The rule is a forward safeguard, and its measured effect on this store is zero** — neutralising the exclusion so synth rows do supply keys leaves the report identical in every bucket, with the recomputed edge sets differing by 0 and the no-log sets by 0. 20,852 beliefs take a different sort key under that arm, but only **5** are session-scoped and each of those also carries a non-synth row, so nothing moves. "Exact in both directions" is a property of the column, not a measured effect, and the effect is worth naming as inert here before it is read as the reason the rule earns its place: it earns it against a store whose migration ran at a different time, which this one did not. Two heuristic detectors were measured first and both failed loudly: a prefix-versus-`ts` disagreement threshold catches legitimately delayed derivation, flags 30,738 rows and collapses reproduction to 34.75%; excluding the largest ULID-prefix date cluster would drop **51.8% of the log**, because that cluster is a real bulk backfill carrying 20,095 session-scoped beliefs, not a synth event (`benchmarks/ingest_log_ulid_clusters.py`). A belief takes its **earliest** qualifying log row, since later rows are corroborations and only the first records insertion. Beliefs with no qualifying row sort last within their `created_at` group — **a forward convention, explicitly not a recovery**: 2,426 such beliefs carry only 433 distinct timestamps, so 94% sit inside a tie where the only other durable column is a content-addressed id, and three placement rules were measured with one link of spread between them. That ordering is **unreconstructible**, and saying so is the deliverable rather than a caveat. Divergence is reported in **three buckets, never one percentage**, because only one is a defect anyone can fix and a single number lets the unreconstructible bucket mask a real key disagreement: on the development store, 41,929 shipped and 39,280 reproduced (93.68%), missing 2,100 no-log / 546 fan-in / **3 other**. Those figures reproduce the issue's gate measurement and its correction to the digit from an independent implementation. The recompute is read-only and opened read-only (#1328); 0.86 s on a 44,594-belief store. From 7306bf0e17cbc7ed4a2e53a99aeba506fd16132b Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:04:20 -0700 Subject: [PATCH 07/15] test(detectors): close two evasions in the writer-coverage sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR body claims the sweep "cannot be evaded with raw SQL" and that keeping the raw-SQL arm "means arm 3 cannot be quietly defeated". Neither held. `_RAW_SQL_RE` matched only a bare or `OR`-qualified `INSERT INTO edges`, so `REPLACE INTO edges` evaded it — and REPLACE is the spelling a new writer is most likely to reach for, since `insert_edge` issues a bare INSERT against a `PRIMARY KEY (src, dst, type)` table and raises on re-write. `INSERT INTO "edges"`, `[edges]` and `main.edges` evaded it too, and the qualified form is already house style (`INSERT INTO temp.fts` in store.py). Such a module is invisible to both arms at once: no `.insert_edge(` for the call-site sweep, no match here. Verified by adding a `REPLACE INTO edges` module — green before, red after — and the widened pattern still resolves to exactly ["aelfrice.store"] on the live tree. A trailing `\b` also drops a false positive the old pattern had on `edges_backup`. Separately, `manifest_digest()` covers the version and THRESHOLDS but not the two coverage lists, so moving a module from covered to excluded moves no digest and no version. That silently voids `test_covered_modules_all_have_entries` for the module while its entries stay in THRESHOLDS claiming to gate edges the exclusion says it does not decide. Asserted directly; mutation-verified. --- .../test_detector_thresholds_manifest_1355.py | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/test_detector_thresholds_manifest_1355.py b/tests/test_detector_thresholds_manifest_1355.py index 8fcae2fff..558aa3d7c 100644 --- a/tests/test_detector_thresholds_manifest_1355.py +++ b/tests/test_detector_thresholds_manifest_1355.py @@ -53,7 +53,20 @@ _CALL_RE = re.compile(r"\.insert_edge\s*\(") # A writer that reaches the table directly would bypass the call-site # sweep entirely, so it is checked separately. -_RAW_SQL_RE = re.compile(r"INSERT\s+(?:OR\s+\w+\s+)?INTO\s+edges", re.IGNORECASE) +# +# `REPLACE INTO` has to be here, not as a nicety: `MemoryStore.insert_edge` +# issues a bare INSERT against a `PRIMARY KEY (src, dst, type)` table, so a +# module wanting an idempotent write reaches for REPLACE first — the one +# spelling most likely to be used is the one the first pattern could not see. +# The table name is also matched through a schema qualifier and the three +# quoting styles SQLite accepts; `store.py` already ships a qualified write +# (`INSERT INTO temp.fts ...`), so that form is house style rather than +# hypothetical. The trailing `\b` additionally stops `edges_backup` and +# `edge_versions` from reading as writes to `edges`. +_RAW_SQL_RE = re.compile( + r"\b(?:INSERT(?:\s+OR\s+\w+)?|REPLACE)\s+INTO\s+(?:\w+\s*\.\s*)?[\"'`\[]?edges\b", + re.IGNORECASE, +) def _module_name(path: Path) -> str: @@ -253,6 +266,18 @@ def test_covered_and_excluded_do_not_overlap() -> None: for module, reason in EXCLUDED_WRITERS: assert reason.strip(), f"{module} excluded without a reason" + # `manifest_digest()` covers the version and THRESHOLDS, not the two + # coverage lists, so moving a module from covered to excluded moves no + # digest and no version. Without this, that move is silent AND leaves the + # manifest self-contradictory: `test_covered_modules_all_have_entries` + # stops applying to the module while its entries still sit in THRESHOLDS + # claiming to gate edges the exclusion says it does not decide. + pinned = {t.module for t in THRESHOLDS} + assert not (pinned & excluded), ( + f"these modules are excluded as making no detection decision, yet " + f"carry pinned thresholds that say otherwise: {sorted(pinned & excluded)}" + ) + def test_only_the_store_writes_the_edges_table_directly() -> None: """Raw SQL would bypass the call-site sweep arm 3 depends on. From 37bcfabf770c7c2e799c422b82a85de39d64d53e Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:04:20 -0700 Subject: [PATCH 08/15] docs(changelog): the manifest spans 9 modules, not 8 `{t.module for t in THRESHOLDS}` is 9: bm25, contradiction, models, relationship_detector, triple_extractor, value_compare, wonder.dispatch, wonder.lifecycle, wonder_consolidation. The count is prose on both sides and nothing gates it; MANIFEST_DIGEST already goes red on any entry change, so this is a correction rather than a new assertion. --- CHANGELOG/v4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG/v4.md b/CHANGELOG/v4.md index cbed76aa0..cc42f567c 100644 --- a/CHANGELOG/v4.md +++ b/CHANGELOG/v4.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`ingest_log.derived_edge_ids` now populates, forward-only, and an edge-set divergence is drift ([#1354](https://github.com/robotrocketscience/aelfrice/issues/1354), [#1157](https://github.com/robotrocketscience/aelfrice/issues/1157) AC3/AC4).** The column was NULL on **every** row (0 of 140,251 on the development store) because all six `derive()` return paths hardcoded `edges=[]`, so no edge had a log row pointing at its origin. `derive()` now emits `DERIVED_FROM` from a reserved `raw_meta.derived_from` block carrying the preceding sentence's verbatim text, and the derivation worker stamps what it emitted. The input travels in `raw_meta` rather than as a `DerivationInput` field because `raw_meta` is already passed through verbatim by both reconstructors — the worker and the replay harness — so it cannot drift between them; text rather than an id keeps `_belief_id` the single minter. **Forward-only with no migration and no new column:** `[]` now means "derived none" and SQL `NULL` means "no edge-aware writer ever saw this row", and since `list_unstamped_ingest_log` filters on `derived_belief_ids IS NULL`, no pass ever revisits a stamped row. The replay probe's edge comparison moves out of the informational `mutable_divergence` bucket into its own `edge_set_divergence` counter that **does** trigger `has_drift` — guarded on the raw column being non-NULL, not on the decoded set, because `_logged_edge_set` collapses NULL, `'[]'` and corruption to the same empty set and a guard written against its output would exempt corruption along with history. **Two corrections to the issue's own premise, both measured.** `DERIVED_FROM` is not emittable without new inputs: neither production writer's target is on the log row — the intra-turn one resolves it from the worker's outcomes map *after* derivation, the inter-turn one from an in-memory per-session dict. And the reachable share is much smaller than the `8.9%` the issue scoped from: only the intra-turn writer is liftable, and attributing the live set by endpoint timestamp puts it at **at most 918 of 47,533 edges (1.93%)** — 21.7% of `DERIVED_FROM`, not all of it. The inter-turn writer's `src` is the turn's *last newly-inserted* belief, which is not knowable at `record_ingest` time. Also fixes a latent crash this would otherwise have shipped: the worker's edge loop was a bare `INSERT` into a table keyed `PRIMARY KEY (src, dst, type)` with no per-row `try/except`, so the first `derive()` to emit an edge turned re-ingest — the documented idempotency contract — into an `IntegrityError`. -- **The thresholds behind the 2.8% non-spine edges are pinned and versioned, so "edges are recomputable" is now checkable for them ([#1355](https://github.com/robotrocketscience/aelfrice/issues/1355), [#1283](https://github.com/robotrocketscience/aelfrice/issues/1283) AC2).** #1283 restated AC2 in two halves; the recompute half shipped, and this is the other one. Edges that are neither `TEMPORAL_NEXT` nor `DERIVED_FROM` are a function of the belief set **and** of detector thresholds, so recomputability holds only if those thresholds are pinned — and they were bare module constants with no guard at all. `aelfrice.detector_thresholds` records **22 constants across 8 modules** behind `DETECTOR_THRESHOLDS_VERSION = 1`: the five `relationship_detector` cutoffs and caps plus its quantifier axis and three token/pattern tables, `contradiction.SUPERSEDES_WEIGHT` and its precedence ladder, the `triple_extractor` phrase-to-edge-type table and anchor width, four `value_compare` slot-gate constants, and the constants on the two paths that actually decide which phantoms reach the `RELATES_TO` writer. **The defect being fixed was a test that could not fail.** `tests/test_relationship_detector.py` asserted `cfg.jaccard_min == DEFAULT_JACCARD_MIN`, comparing the constant to itself; changing `DEFAULT_JACCARD_MIN` from 0.4 to **0.9** in source leaves that test **green**, which is measured here rather than asserted — the same defect class as the #1353 synth-exclusion pin. The manifest holds hand-written literals and **imports nothing from `aelfrice`**, precisely so it cannot repeat that mistake: the test does the importing and re-derives each pinned form from the live object, and scalars must stay literals (a separate arm rejects "repairing" a red scalar by converting it to a digest). Collections pin as a digest of a canonical form that **includes regex flags** — dropping `re.IGNORECASE` from the triple patterns changes no pattern text but changes which triples match, and it goes red. A digest over the whole manifest **includes the version**, which is what makes "changing a value forces a version bump" mechanical: editing a pinned value fails twice, once on the live-source mismatch and once on the digest, and a comment-only edit stays green because the digest is over data, not source text. Coverage is **swept from source, not hand-listed** — every module calling `insert_edge` must be either pinned or excluded with a stated reason (**4 covered, 6 excluded**: the spine, the two `DERIVED_FROM` paths, the cross-store copier, and two synthetic-fixture builders), and a separate arm asserts only the store writes the `edges` table directly, so the sweep cannot be evaded with raw SQL. **Reachability was checked, not assumed, and it moved two things.** A first pass pinned six `wonder.{evaluator,strategies}` constants as the gate on `RELATES_TO`; the package docstring says outright that those strategies are research-only and do not write to a live store, and their sole importer builds against `MemoryStore(":memory:")`, so they decide no edge in any user's store and were dropped rather than shipped with `gates` text that could not be true. The paths that *do* decide it — BFS hops ranked by `wonder_consolidation.score` then sliced by `--top`, and the dispatch-seeded persist-docs path — are pinned instead. The four `value_compare` entries are kept but relabelled **dormant**: no shipped caller passes `use_value_comparison=True`, so the slot gate mints nothing today, and pinning it now makes flipping that flag a one-line change against a known baseline. Relatedly, the coverage sweep can only see modules that *call* `insert_edge`, so three upstream suppliers were added by hand — `bm25._TOKEN_PATTERN` (the token universe for every Jaccard and membership test in the detector), `models.ANCHOR_TEXT_MAX_LEN`, and `wonder_consolidation._TOKENIZER_DROP` — and the two known-unpinned suppliers (`dedup`'s prefilter semantics, `config_discovery`'s file resolution) are named in the module docstring so their absence is a recorded decision. +- **The thresholds behind the 2.8% non-spine edges are pinned and versioned, so "edges are recomputable" is now checkable for them ([#1355](https://github.com/robotrocketscience/aelfrice/issues/1355), [#1283](https://github.com/robotrocketscience/aelfrice/issues/1283) AC2).** #1283 restated AC2 in two halves; the recompute half shipped, and this is the other one. Edges that are neither `TEMPORAL_NEXT` nor `DERIVED_FROM` are a function of the belief set **and** of detector thresholds, so recomputability holds only if those thresholds are pinned — and they were bare module constants with no guard at all. `aelfrice.detector_thresholds` records **22 constants across 9 modules** behind `DETECTOR_THRESHOLDS_VERSION = 1`: the five `relationship_detector` cutoffs and caps plus its quantifier axis and three token/pattern tables, `contradiction.SUPERSEDES_WEIGHT` and its precedence ladder, the `triple_extractor` phrase-to-edge-type table and anchor width, four `value_compare` slot-gate constants, and the constants on the two paths that actually decide which phantoms reach the `RELATES_TO` writer. **The defect being fixed was a test that could not fail.** `tests/test_relationship_detector.py` asserted `cfg.jaccard_min == DEFAULT_JACCARD_MIN`, comparing the constant to itself; changing `DEFAULT_JACCARD_MIN` from 0.4 to **0.9** in source leaves that test **green**, which is measured here rather than asserted — the same defect class as the #1353 synth-exclusion pin. The manifest holds hand-written literals and **imports nothing from `aelfrice`**, precisely so it cannot repeat that mistake: the test does the importing and re-derives each pinned form from the live object, and scalars must stay literals (a separate arm rejects "repairing" a red scalar by converting it to a digest). Collections pin as a digest of a canonical form that **includes regex flags** — dropping `re.IGNORECASE` from the triple patterns changes no pattern text but changes which triples match, and it goes red. A digest over the whole manifest **includes the version**, which is what makes "changing a value forces a version bump" mechanical: editing a pinned value fails twice, once on the live-source mismatch and once on the digest, and a comment-only edit stays green because the digest is over data, not source text. Coverage is **swept from source, not hand-listed** — every module calling `insert_edge` must be either pinned or excluded with a stated reason (**4 covered, 6 excluded**: the spine, the two `DERIVED_FROM` paths, the cross-store copier, and two synthetic-fixture builders), and a separate arm asserts only the store writes the `edges` table directly, so the sweep cannot be evaded with raw SQL. **Reachability was checked, not assumed, and it moved two things.** A first pass pinned six `wonder.{evaluator,strategies}` constants as the gate on `RELATES_TO`; the package docstring says outright that those strategies are research-only and do not write to a live store, and their sole importer builds against `MemoryStore(":memory:")`, so they decide no edge in any user's store and were dropped rather than shipped with `gates` text that could not be true. The paths that *do* decide it — BFS hops ranked by `wonder_consolidation.score` then sliced by `--top`, and the dispatch-seeded persist-docs path — are pinned instead. The four `value_compare` entries are kept but relabelled **dormant**: no shipped caller passes `use_value_comparison=True`, so the slot gate mints nothing today, and pinning it now makes flipping that flag a one-line change against a known baseline. Relatedly, the coverage sweep can only see modules that *call* `insert_edge`, so three upstream suppliers were added by hand — `bm25._TOKEN_PATTERN` (the token universe for every Jaccard and membership test in the detector), `models.ANCHOR_TEXT_MAX_LEN`, and `wonder_consolidation._TOKENIZER_DROP` — and the two known-unpinned suppliers (`dedup`'s prefilter semantics, `config_discovery`'s file resolution) are named in the module docstring so their absence is a recorded decision. **Two limits stated rather than papered over.** Three of the entries are defaults a `.aelfrice.toml` `[relationship_detector]` section can override, so for those the manifest pins the *shipped default*, not the value a given store actually ran with; every entry names its override mechanism so the two are distinguishable. And this is **forward-only** — the `edges` table has no version and no `created_at`, so pinning today does not make a historical edge attributable to the thresholds that produced it. Adding those columns is the `edges`-table migration that left stores unopenable-forever in #1161, and historical reproduction stays explicitly out of scope. - **The injected block can now carry the evidence behind each belief, grouped by trust tier ([#1326](https://github.com/robotrocketscience/aelfrice/issues/1326), [#1177](https://github.com/robotrocketscience/aelfrice/issues/1177) proposal 18).** The per-turn line rendered `id`, `lock` and (since #1171) `speculative`, and threw away everything else the store knows about how far to trust a belief. `[hook] provenance_render` (default-**off**, `AELFRICE_PROVENANCE_RENDER` overrides) groups the block into `` / `` / `` with a framing clause each, and emits `origin`, `n` (= `alpha + beta`), `mu` and `seen` on non-locked lines. Every value is already on the belief at render time — measured, all four populated on **74 of 74** hits in a live pack — so there is no new query. The point is `n`: `mu = 0.6 at n = 2` is byte-identical to `mu = 0.6 at n = 200` at every scoring site, and one live pack carried **25 distinct `n` values from 1.6 to 363.2** inside a single turn's block, so the signal the ranker must collapse is one the model can weigh contextually. Section membership is a **total** function of `lock_level` and `origin` — the proposal as filed classified origins with two literal sets that between them stranded **6,396 active beliefs (14.3%)** in no section at all, and named two origins (`commit`, `file`) that do not exist; a renderer written to it would have dropped 14.3% of the block with no error. Every `models.ORIGIN_*` constant is now classified, an unrecognised origin falls back to `` rather than vanishing, and a test enumerates the constants from `models` so a new origin cannot be added without being classified. `speculative="1"` is folded into `origin="speculative"` rather than emitted alongside it, while the #1171 framing sentence still fires. With the flag off the block is byte-identical to before, asserted against literal expected bytes rather than recomputed. From 1ff83230b3ff2f6ec3409c40da08977226ac1051 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:18:24 -0700 Subject: [PATCH 09/15] fix(detectors): key the manifest digest by version so a value change cannot be absorbed Review found the module's headline claim false, and it was: a lone MANIFEST_DIGEST literal sitting beside the thing it digests forces nothing. Edit a constant, edit its manifest entry, edit the digest, and the suite is green with DETECTOR_THRESHOLDS_VERSION untouched -- two different edge-producing behaviours shipping as version 1. That is verbatim failure mode 2 in the test file's own docstring, and the arm's failure message instructed exactly the repair that causes it. Replaces the literal with DIGEST_HISTORY, keyed by version, and digests content only (the version is now the key, so folding it into the payload would move the digest for reasons unrelated to what the manifest says). The cheap repair is gone: the ways back to green are to revert, or to bump and APPEND a row. A second arm pins the history contiguous over 1..VERSION with no duplicate digests, so a bump cannot skip a row. MANIFEST_DIGEST is derived via .get rather than a subscript -- bumping without appending is a contract breach the tests should name, and an import-time KeyError would take the module down and surface as a collection error in every unrelated test that imports it. Stated rather than overclaimed: overwriting a historical row still works. Only a merge-base check in CI is fully mechanical, and that is not bundled here. Verified by mutation with caches cleared between runs (same-length digests are otherwise served from a stale .pyc): the reported repro -- source 0.4 -> 0.9 plus the matching manifest edit -- previously passed 31/31 and now fails; bumping without appending fails two arms cleanly; bumping and appending is green. --- src/aelfrice/detector_thresholds.py | 62 ++++++++++++++----- .../test_detector_thresholds_manifest_1355.py | 57 +++++++++++++---- 2 files changed, 92 insertions(+), 27 deletions(-) diff --git a/src/aelfrice/detector_thresholds.py b/src/aelfrice/detector_thresholds.py index ad70c93af..de689ecd2 100644 --- a/src/aelfrice/detector_thresholds.py +++ b/src/aelfrice/detector_thresholds.py @@ -30,8 +30,10 @@ columns is an ``edges``-table migration — the operation that left stores unopenable-forever in #1161 — and historical reproduction is explicitly out of scope here. What this buys is that from ``DETECTOR_THRESHOLDS_VERSION -= 1`` onward, a change to any listed value cannot land without bumping the -version, because :data:`MANIFEST_DIGEST` goes red. += 1`` onward, a change to any listed value makes :data:`DIGEST_HISTORY` +disagree with the pinned content, so landing it means either bumping the +version and appending a row or visibly rewriting a historical one. See +that constant for what this does and does not enforce. **Honesty about overrides.** Several entries are defaults that a config file or environment variable can override at runtime — ``jaccard_min``, @@ -83,9 +85,9 @@ # evaluated at runtime and importing it unconditionally reads as dead. from collections.abc import Sequence -# Bump when any pinned value below changes. The digest guard in -# tests/test_detector_thresholds_manifest_1355.py makes this mechanical: -# edit a value without bumping, and the test names both. +# Bump when any pinned value below changes, and append the new content +# digest to DIGEST_HISTORY at the bottom of this module. The guard in +# tests/test_detector_thresholds_manifest_1355.py fails until you do. DETECTOR_THRESHOLDS_VERSION: Final[int] = 1 # --- Kinds ------------------------------------------------------------- @@ -608,19 +610,50 @@ class PinnedThreshold: # --- Digest guard ------------------------------------------------------ -# sha256 over the whole manifest. Editing any pinned value changes this, -# which is what forces DETECTOR_THRESHOLDS_VERSION to move with it. -MANIFEST_DIGEST: Final[str] = ( - "1edce3ea3ddd950f7a81201a6fda33cae89da96332abd0f4c2d20bcfb0053c02" +# Content digest of THRESHOLDS at each version. **Append a row when you +# bump the version; never rewrite one.** +# +# This is keyed by version rather than held as a single literal for a +# specific reason. A lone `MANIFEST_DIGEST = ""` sitting beside the +# thing it digests does not force anything: edit a constant, edit its +# manifest entry, edit the digest, and the suite is green again with the +# version untouched — two different edge-producing behaviours both +# shipping as version 1. That is failure mode 2 in the test file's own +# docstring, and a bare literal invites exactly the repair that causes it. +# +# Keyed by version, the cheap repair is gone: a content change makes +# `manifest_digest()` disagree with `DIGEST_HISTORY[VERSION]`, and the +# ways back to green are to revert, or to bump the version and append a +# row. Overwriting a historical row still works, but it is a visibly +# dishonest edit in the diff rather than the obvious one. +# +# Honest limit: this raises the cost of the wrong move, it does not make +# it impossible. Only a check against the merge-base — if the THRESHOLDS +# digest differs from `main`'s, require VERSION to have increased — is +# truly mechanical. That belongs in CI and is deliberately not built here. +DIGEST_HISTORY: Final[dict[int, str]] = { + 1: "6e516b17be9b76fce3006b4a0e02efadc9bcddc28db6472537cd1f7fa4675510", +} + +# The digest the current version must produce. Derived, never hand-edited. +# `.get` rather than `[...]`: bumping the version without appending a row +# is a contract breach the tests should NAME, and an import-time KeyError +# would instead take the whole module down and report as a collection +# error in every unrelated test that imports it. +MANIFEST_DIGEST: Final[str] = DIGEST_HISTORY.get( + DETECTOR_THRESHOLDS_VERSION, "" ) def manifest_digest() -> str: - """Digest the manifest, including the version it was recorded under.""" - payload = { - "version": DETECTOR_THRESHOLDS_VERSION, - "thresholds": [_canonical(t) for t in THRESHOLDS], - } + """Digest the pinned content, independent of the version it ships under. + + Content-only on purpose: the version is the key into + :data:`DIGEST_HISTORY`, so folding it into the digested payload would + make every version bump change the digest for reasons unrelated to + what the manifest says. + """ + payload = {"thresholds": [_canonical(t) for t in THRESHOLDS]} return hashlib.sha256(_dumps(payload).encode("utf-8")).hexdigest() @@ -676,6 +709,7 @@ def manifest_digest() -> str: "DETECTOR_THRESHOLDS_VERSION", "EXCLUDED_WRITERS", "KINDS", + "DIGEST_HISTORY", "MANIFEST_DIGEST", "PinnedThreshold", "THRESHOLDS", diff --git a/tests/test_detector_thresholds_manifest_1355.py b/tests/test_detector_thresholds_manifest_1355.py index 558aa3d7c..031ab97d5 100644 --- a/tests/test_detector_thresholds_manifest_1355.py +++ b/tests/test_detector_thresholds_manifest_1355.py @@ -10,7 +10,11 @@ manifest to match their new constant and ships, leaving ``DETECTOR_THRESHOLDS_VERSION`` at 1 — so two different edge-producing behaviours claim the same version. - → ``test_manifest_digest_is_pinned``. + → ``test_manifest_digest_is_pinned`` + + ``test_digest_history_is_contiguous_and_complete``, which together make + the repair an append to ``DIGEST_HISTORY`` rather than an edit to one + literal. Not airtight — a historical row can still be overwritten — and + the module says so rather than overclaiming. 3. **A new writer lands unpinned.** Someone adds a module that writes a non-spine edge and never touches the manifest. @@ -37,6 +41,7 @@ DETECTOR_THRESHOLDS_VERSION, EXCLUDED_WRITERS, KINDS, + DIGEST_HISTORY, MANIFEST_DIGEST, PinnedThreshold, THRESHOLDS, @@ -182,22 +187,48 @@ def test_entries_are_wellformed_and_unique() -> None: def test_manifest_digest_is_pinned() -> None: - """The digest covers the version, so the two move together. + """The pinned content must match the digest recorded for this version. + + The earlier shape of this test held a single hand-written + ``MANIFEST_DIGEST`` literal, and it did NOT close failure mode 2 above + — it only announced it. Editing a constant, its manifest entry and the + digest literal returned the suite to green with the version untouched, + which is precisely two behaviours shipping as version 1. The failure + message even instructed that repair. + + Keyed by version, the cheap repair is gone: the ways back to green are + to revert, or to bump the version and append a row to + ``DIGEST_HISTORY``. Overwriting a historical row still works and is + the honest limit of this mechanism — see the constant's comment. Only + a merge-base check in CI is fully mechanical, and that is not built. + """ + assert manifest_digest() == DIGEST_HISTORY[DETECTOR_THRESHOLDS_VERSION], ( + f"pinned content does not match the digest recorded for version " + f"{DETECTOR_THRESHOLDS_VERSION}. Revert the change, or bump " + f"DETECTOR_THRESHOLDS_VERSION and APPEND a row to DIGEST_HISTORY. " + f"Do not rewrite the existing row." + ) + assert MANIFEST_DIGEST == DIGEST_HISTORY[DETECTOR_THRESHOLDS_VERSION] - This is what makes "changing a value forces a version bump" mechanical - rather than a convention. Editing a pinned value changes - ``manifest_digest()``; the only way back to green is to update - ``MANIFEST_DIGEST``, and a reviewer seeing that line move knows to look - for the version bump beside it. - Verified by mutation, both directions: bumping - ``DETECTOR_THRESHOLDS_VERSION`` alone turns this red, and editing any - pinned value alone turns this red. +def test_digest_history_is_contiguous_and_complete() -> None: + """Every version from 1 to the current one has exactly one digest. + + Without this, bumping the version without appending a row raises + KeyError in a place that reads like a crash rather than a contract + breach, and a gap in the history would let a version be skipped to + dodge a row. """ - assert manifest_digest() == MANIFEST_DIGEST, ( - "manifest content or version changed without updating " - "MANIFEST_DIGEST" + assert set(DIGEST_HISTORY) == set(range(1, DETECTOR_THRESHOLDS_VERSION + 1)), ( + f"DIGEST_HISTORY keys {sorted(DIGEST_HISTORY)} are not exactly " + f"1..{DETECTOR_THRESHOLDS_VERSION}" + ) + assert len(set(DIGEST_HISTORY.values())) == len(DIGEST_HISTORY), ( + "two versions record the same content digest — one of them did " + "not need a bump" ) + for version, digest in DIGEST_HISTORY.items(): + assert len(digest) == 64, f"version {version}: not a sha256 hex digest" def test_version_is_a_positive_int() -> None: From 91394976ebb8fabceff332aec455105522c7ccce Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:18:41 -0700 Subject: [PATCH 10/15] fix(detectors): correct five gates strings and the TEMPORAL_NEXT exclusion `gates` is the manifest's substance -- "a constant that cannot answer this does not belong here" -- and nothing tests these strings, so five wrong ones shipped. All five were reproduced against live source before being rewritten. - SUPERSEDES_WEIGHT claimed "propagation arithmetic". There is none: Edge.weight is read by a BFS sort key, a clustering floor and persistence. Propagation is EDGE_VALENCE, keyed on edge TYPE, which this constant does not touch. The real effect is sharper -- at 1.0 it clears DEFAULT_CLUSTER_EDGE_FLOOR (0.4), and any value below that silently drops every SUPERSEDES edge out of candidate clustering. - UNCERTAINTY_THRESHOLD claimed to filter the anchor tuple. It does not: `anchors` is built from the unfiltered `known_beliefs`. It selects high_uncertainty_beliefs, which decides whether an uncertainty_deep_dive axis is emitted -- still a real path to RELATES_TO, but not the stated one. - QUANT_AXIS said the score is half the axis distance. It is a quarter: q_term halves the distance and the score halves it again. `always` vs `sometimes` is 1.0 apart and scores 0.25, so it lands as POTENTIALLY_STALE rather than CONTRADICTS -- the wrong side of the very split DEFAULT_CONFIDENCE_MIN exists to record. Sizing an edit with the old prose picks the wrong value. - DEFAULT_JACCARD_MIN claimed lowering it "can only add edges". The candidate pool is monotonic; the written set is not, because DEFAULT_MAX_EDGES_PER_BELIEF is spent in sorted pair order, so a newly-admitted pair can evict a previously written one. - _PATTERNS omitted TEMPORAL_NEXT from edge_types while four of its 25 patterns mint exactly that type (`follows`, `comes after`, `is after`, `succeeds`). That last one has a consequence outside the manifest, so the exclusion entry now carries it: EXCLUDED_WRITERS said temporal_spine "writes TEMPORAL_NEXT only", which reads as the spine accounting for the whole TEMPORAL_NEXT population. It does not -- triple_extractor is a second, prose-driven producer the #1336 spine recompute does not cover. Also records two limits the manifest was silent on. `--axes-budget` (default 24) caps the anchor tuple and so how many RELATES_TO edges each persisted phantom writes -- a bigger lever than several pinned constants, but a signature default rather than a module constant, out of reach of the (module, name) scheme for the same reason the inline weights are. And belief ARRIVAL ORDER is a third input beside the belief set and these thresholds: the per-belief cap is spent on whichever pairs arrived first, so full-store and incremental runs can disagree on identical beliefs, and re-deriving edges from beliefs plus this manifest gives a false mismatch on any incrementally built store -- which is every real one. --- src/aelfrice/detector_thresholds.py | 70 ++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/src/aelfrice/detector_thresholds.py b/src/aelfrice/detector_thresholds.py index de689ecd2..df47fd6ec 100644 --- a/src/aelfrice/detector_thresholds.py +++ b/src/aelfrice/detector_thresholds.py @@ -70,7 +70,24 @@ supplies the overridable values above. Both are behavioural surfaces rather than constants; pinning them means pinning functions, which this mechanism does not do. They are named here so their absence is a recorded -decision rather than an oversight. +decision rather than an oversight. So is ``--axes-budget`` (default 24 on +``analyze_gaps`` / ``build_dispatch_payload``), which caps the anchor tuple +and therefore how many ``RELATES_TO`` edges each persisted phantom writes: +a bigger lever than several constants pinned below, but a signature default +rather than a module constant, so it is out of reach of the ``(module, +name)`` scheme for the same reason the inline weights are. + +**Pinning these is necessary, not sufficient.** Two stores with identical +belief sets and every value below at its manifest reading can still hold +different ``CONTRADICTS`` edges, because ``DEFAULT_MAX_EDGES_PER_BELIEF`` is +consumed in sorted pair order and the shipped path is incremental — the cap +is spent on whichever pairs arrived first. A full-store +``write_semantic_edges()`` and the incremental +``write_semantic_edges(new_belief_ids=[…])`` can therefore disagree on the +same beliefs. So belief arrival ORDER is a third input alongside the belief +set and these thresholds, and anyone re-deriving edges from beliefs plus +this manifest will see a false mismatch on any incrementally built store — +which is every real one. """ from __future__ import annotations @@ -252,7 +269,10 @@ class PinnedThreshold: gates=( "Token-overlap floor a pair must clear to enter the " "classifier at all. Lowering it enlarges the candidate pool " - "and can only add edges; raising it can only remove them." + "monotonically, but the written edge set does NOT move " + "monotonically with it: `DEFAULT_MAX_EDGES_PER_BELIEF` is " + "consumed in sorted pair order, so a newly-admitted pair can " + "evict one that was previously written." ), ), PinnedThreshold( @@ -320,9 +340,13 @@ class PinnedThreshold: edge_types=("CONTRADICTS", "POTENTIALLY_STALE"), overridable=OVERRIDE_NONE, gates=( - "Quantifier positions on the frequency axis. The score is " - "half the axis distance, so moving any value moves the score " - "across confidence_min and reclassifies the pair." + "Quantifier positions on the frequency axis. The axis " + "distance is halved into `q_term` and the score halves it " + "again, so a pure quantifier disagreement scores a QUARTER " + "of the distance: `always` vs `sometimes` is 1.0 apart and " + "scores 0.25 — below `confidence_min`, so it lands as " + "POTENTIALLY_STALE, not CONTRADICTS. Size any edit here " + "against 4x the axis gap, not 2x." ), ), PinnedThreshold( @@ -379,8 +403,13 @@ class PinnedThreshold: overridable=OVERRIDE_NONE, gates=( "Weight stamped on every SUPERSEDES edge. Does not change " - "which edges are written, but does change the propagation " - "arithmetic a recompute must reproduce byte-for-byte." + "which edges are written. It does decide whether they are " + "visible downstream: `clustering` drops any edge below " + "`DEFAULT_CLUSTER_EDGE_FLOOR` (0.4), so at 1.0 these clear " + "the floor and any value under 0.4 silently removes every " + "SUPERSEDES edge from candidate clustering. It is also a BFS " + "sort key. Note this is `Edge.weight`, not `EDGE_VALENCE` — " + "valence is keyed on edge TYPE and is untouched by this." ), ), PinnedThreshold( @@ -410,12 +439,16 @@ class PinnedThreshold: edge_types=( "SUPPORTS", "CITES", "CONTRADICTS", "SUPERSEDES", "RELATES_TO", "DERIVED_FROM", "IMPLEMENTS", "TESTS", + "TEMPORAL_NEXT", ), overridable=OVERRIDE_NONE, gates=( "The phrase-to-edge-type table. This is the only writer that " "chooses among most edge types, so it decides both whether " - "an edge exists and which type it is." + "an edge exists and which type it is. Note it includes four " + "TEMPORAL_NEXT patterns (`follows`, `comes after`, `is " + "after`, `succeeds`), so the spine is NOT the only producer " + "of that type — see EXCLUDED_WRITERS." ), ), PinnedThreshold( @@ -546,11 +579,14 @@ class PinnedThreshold: edge_types=("RELATES_TO",), overridable=OVERRIDE_NONE, gates=( - "Posterior-uncertainty floor for a belief to become a wonder " - "anchor in the `--axes` dispatch payload. Those anchors are " - "the constituent tuple `skill_integration` persists, so the " - "floor decides which RELATES_TO edges the persist-docs path " - "writes. It does NOT touch the BFS path in item 1 above." + "Posterior-uncertainty floor selecting " + "`high_uncertainty_beliefs`, which decides whether an " + "`uncertainty_deep_dive` research axis is emitted at all. " + "That axis is a document, and documents become phantoms with " + "RELATES_TO edges, so the floor gates a whole class of them. " + "It does NOT filter the anchor tuple — `anchors` is built " + "from the unfiltered `known_beliefs` — and it does not touch " + "the BFS path in item 1 above." ), ), PinnedThreshold( @@ -632,7 +668,7 @@ class PinnedThreshold: # digest differs from `main`'s, require VERSION to have increased — is # truly mechanical. That belongs in CI and is deliberately not built here. DIGEST_HISTORY: Final[dict[int, str]] = { - 1: "6e516b17be9b76fce3006b4a0e02efadc9bcddc28db6472537cd1f7fa4675510", + 1: "ffaaca91fa8e74cb9d79d9a9322cf8ce0d3d3d41ac628694609e7c1fbbaeec74", } # The digest the current version must produce. Derived, never hand-edited. @@ -675,7 +711,11 @@ def manifest_digest() -> str: EXCLUDED_WRITERS: Final[tuple[tuple[str, str], ...]] = ( ( "aelfrice.temporal_spine", - "writes TEMPORAL_NEXT only — the spine, recomputed by #1336", + "writes TEMPORAL_NEXT only — the spine, recomputed by #1336. " + "Read as a statement about THIS module, not about the type: " + "`triple_extractor` also mints TEMPORAL_NEXT from four prose " + "patterns, so the spine recompute does not account for the " + "whole TEMPORAL_NEXT population", ), ( "aelfrice.ingest", From 75658892b1ee72438be48466ce74ae4f67791338 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:18:41 -0700 Subject: [PATCH 11/15] docs(changelog): state what the digest guard enforces and what it does not (#1355) --- CHANGELOG/v4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG/v4.md b/CHANGELOG/v4.md index cc42f567c..1d086db55 100644 --- a/CHANGELOG/v4.md +++ b/CHANGELOG/v4.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`ingest_log.derived_edge_ids` now populates, forward-only, and an edge-set divergence is drift ([#1354](https://github.com/robotrocketscience/aelfrice/issues/1354), [#1157](https://github.com/robotrocketscience/aelfrice/issues/1157) AC3/AC4).** The column was NULL on **every** row (0 of 140,251 on the development store) because all six `derive()` return paths hardcoded `edges=[]`, so no edge had a log row pointing at its origin. `derive()` now emits `DERIVED_FROM` from a reserved `raw_meta.derived_from` block carrying the preceding sentence's verbatim text, and the derivation worker stamps what it emitted. The input travels in `raw_meta` rather than as a `DerivationInput` field because `raw_meta` is already passed through verbatim by both reconstructors — the worker and the replay harness — so it cannot drift between them; text rather than an id keeps `_belief_id` the single minter. **Forward-only with no migration and no new column:** `[]` now means "derived none" and SQL `NULL` means "no edge-aware writer ever saw this row", and since `list_unstamped_ingest_log` filters on `derived_belief_ids IS NULL`, no pass ever revisits a stamped row. The replay probe's edge comparison moves out of the informational `mutable_divergence` bucket into its own `edge_set_divergence` counter that **does** trigger `has_drift` — guarded on the raw column being non-NULL, not on the decoded set, because `_logged_edge_set` collapses NULL, `'[]'` and corruption to the same empty set and a guard written against its output would exempt corruption along with history. **Two corrections to the issue's own premise, both measured.** `DERIVED_FROM` is not emittable without new inputs: neither production writer's target is on the log row — the intra-turn one resolves it from the worker's outcomes map *after* derivation, the inter-turn one from an in-memory per-session dict. And the reachable share is much smaller than the `8.9%` the issue scoped from: only the intra-turn writer is liftable, and attributing the live set by endpoint timestamp puts it at **at most 918 of 47,533 edges (1.93%)** — 21.7% of `DERIVED_FROM`, not all of it. The inter-turn writer's `src` is the turn's *last newly-inserted* belief, which is not knowable at `record_ingest` time. Also fixes a latent crash this would otherwise have shipped: the worker's edge loop was a bare `INSERT` into a table keyed `PRIMARY KEY (src, dst, type)` with no per-row `try/except`, so the first `derive()` to emit an edge turned re-ingest — the documented idempotency contract — into an `IntegrityError`. -- **The thresholds behind the 2.8% non-spine edges are pinned and versioned, so "edges are recomputable" is now checkable for them ([#1355](https://github.com/robotrocketscience/aelfrice/issues/1355), [#1283](https://github.com/robotrocketscience/aelfrice/issues/1283) AC2).** #1283 restated AC2 in two halves; the recompute half shipped, and this is the other one. Edges that are neither `TEMPORAL_NEXT` nor `DERIVED_FROM` are a function of the belief set **and** of detector thresholds, so recomputability holds only if those thresholds are pinned — and they were bare module constants with no guard at all. `aelfrice.detector_thresholds` records **22 constants across 9 modules** behind `DETECTOR_THRESHOLDS_VERSION = 1`: the five `relationship_detector` cutoffs and caps plus its quantifier axis and three token/pattern tables, `contradiction.SUPERSEDES_WEIGHT` and its precedence ladder, the `triple_extractor` phrase-to-edge-type table and anchor width, four `value_compare` slot-gate constants, and the constants on the two paths that actually decide which phantoms reach the `RELATES_TO` writer. **The defect being fixed was a test that could not fail.** `tests/test_relationship_detector.py` asserted `cfg.jaccard_min == DEFAULT_JACCARD_MIN`, comparing the constant to itself; changing `DEFAULT_JACCARD_MIN` from 0.4 to **0.9** in source leaves that test **green**, which is measured here rather than asserted — the same defect class as the #1353 synth-exclusion pin. The manifest holds hand-written literals and **imports nothing from `aelfrice`**, precisely so it cannot repeat that mistake: the test does the importing and re-derives each pinned form from the live object, and scalars must stay literals (a separate arm rejects "repairing" a red scalar by converting it to a digest). Collections pin as a digest of a canonical form that **includes regex flags** — dropping `re.IGNORECASE` from the triple patterns changes no pattern text but changes which triples match, and it goes red. A digest over the whole manifest **includes the version**, which is what makes "changing a value forces a version bump" mechanical: editing a pinned value fails twice, once on the live-source mismatch and once on the digest, and a comment-only edit stays green because the digest is over data, not source text. Coverage is **swept from source, not hand-listed** — every module calling `insert_edge` must be either pinned or excluded with a stated reason (**4 covered, 6 excluded**: the spine, the two `DERIVED_FROM` paths, the cross-store copier, and two synthetic-fixture builders), and a separate arm asserts only the store writes the `edges` table directly, so the sweep cannot be evaded with raw SQL. **Reachability was checked, not assumed, and it moved two things.** A first pass pinned six `wonder.{evaluator,strategies}` constants as the gate on `RELATES_TO`; the package docstring says outright that those strategies are research-only and do not write to a live store, and their sole importer builds against `MemoryStore(":memory:")`, so they decide no edge in any user's store and were dropped rather than shipped with `gates` text that could not be true. The paths that *do* decide it — BFS hops ranked by `wonder_consolidation.score` then sliced by `--top`, and the dispatch-seeded persist-docs path — are pinned instead. The four `value_compare` entries are kept but relabelled **dormant**: no shipped caller passes `use_value_comparison=True`, so the slot gate mints nothing today, and pinning it now makes flipping that flag a one-line change against a known baseline. Relatedly, the coverage sweep can only see modules that *call* `insert_edge`, so three upstream suppliers were added by hand — `bm25._TOKEN_PATTERN` (the token universe for every Jaccard and membership test in the detector), `models.ANCHOR_TEXT_MAX_LEN`, and `wonder_consolidation._TOKENIZER_DROP` — and the two known-unpinned suppliers (`dedup`'s prefilter semantics, `config_discovery`'s file resolution) are named in the module docstring so their absence is a recorded decision. +- **The thresholds behind the 2.8% non-spine edges are pinned and versioned, so "edges are recomputable" is now checkable for them ([#1355](https://github.com/robotrocketscience/aelfrice/issues/1355), [#1283](https://github.com/robotrocketscience/aelfrice/issues/1283) AC2).** #1283 restated AC2 in two halves; the recompute half shipped, and this is the other one. Edges that are neither `TEMPORAL_NEXT` nor `DERIVED_FROM` are a function of the belief set **and** of detector thresholds, so recomputability holds only if those thresholds are pinned — and they were bare module constants with no guard at all. `aelfrice.detector_thresholds` records **22 constants across 9 modules** behind `DETECTOR_THRESHOLDS_VERSION = 1`: the five `relationship_detector` cutoffs and caps plus its quantifier axis and three token/pattern tables, `contradiction.SUPERSEDES_WEIGHT` and its precedence ladder, the `triple_extractor` phrase-to-edge-type table and anchor width, four `value_compare` slot-gate constants, and the constants on the two paths that actually decide which phantoms reach the `RELATES_TO` writer. **The defect being fixed was a test that could not fail.** `tests/test_relationship_detector.py` asserted `cfg.jaccard_min == DEFAULT_JACCARD_MIN`, comparing the constant to itself; changing `DEFAULT_JACCARD_MIN` from 0.4 to **0.9** in source leaves that test **green**, which is measured here rather than asserted — the same defect class as the #1353 synth-exclusion pin. The manifest holds hand-written literals and **imports nothing from `aelfrice`**, precisely so it cannot repeat that mistake: the test does the importing and re-derives each pinned form from the live object, and scalars must stay literals (a separate arm rejects "repairing" a red scalar by converting it to a digest). Collections pin as a digest of a canonical form that **includes regex flags** — dropping `re.IGNORECASE` from the triple patterns changes no pattern text but changes which triples match, and it goes red. A content digest is recorded **per version** in `DIGEST_HISTORY` rather than as one literal beside the manifest, and that distinction is the difference between enforcing the version bump and merely announcing it. A single `MANIFEST_DIGEST = ""` does not force anything: edit the constant, edit its manifest entry, edit the digest, and the suite is green again with the version untouched — two different edge-producing behaviours both shipping as version 1, which is the exact failure the version exists to prevent. Keyed by version, that repair is gone: the ways back to green are to revert, or to bump and **append** a row. Stated limit, because the guard is not airtight — a historical row can still be overwritten, and only a merge-base check in CI (if the content digest differs from `main`'s, require the version to have increased) would be fully mechanical; that is deliberately not built here. A comment-only edit stays green, because the digest is over data rather than source text. Coverage is **swept from source, not hand-listed** — every module calling `insert_edge` must be either pinned or excluded with a stated reason (**4 covered, 6 excluded**: the spine, the two `DERIVED_FROM` paths, the cross-store copier, and two synthetic-fixture builders), and a separate arm asserts only the store writes the `edges` table directly, so the sweep cannot be evaded with raw SQL. **Reachability was checked, not assumed, and it moved two things.** A first pass pinned six `wonder.{evaluator,strategies}` constants as the gate on `RELATES_TO`; the package docstring says outright that those strategies are research-only and do not write to a live store, and their sole importer builds against `MemoryStore(":memory:")`, so they decide no edge in any user's store and were dropped rather than shipped with `gates` text that could not be true. The paths that *do* decide it — BFS hops ranked by `wonder_consolidation.score` then sliced by `--top`, and the dispatch-seeded persist-docs path — are pinned instead. The four `value_compare` entries are kept but relabelled **dormant**: no shipped caller passes `use_value_comparison=True`, so the slot gate mints nothing today, and pinning it now makes flipping that flag a one-line change against a known baseline. Relatedly, the coverage sweep can only see modules that *call* `insert_edge`, so three upstream suppliers were added by hand — `bm25._TOKEN_PATTERN` (the token universe for every Jaccard and membership test in the detector), `models.ANCHOR_TEXT_MAX_LEN`, and `wonder_consolidation._TOKENIZER_DROP` — and the two known-unpinned suppliers (`dedup`'s prefilter semantics, `config_discovery`'s file resolution) are named in the module docstring so their absence is a recorded decision. **Two limits stated rather than papered over.** Three of the entries are defaults a `.aelfrice.toml` `[relationship_detector]` section can override, so for those the manifest pins the *shipped default*, not the value a given store actually ran with; every entry names its override mechanism so the two are distinguishable. And this is **forward-only** — the `edges` table has no version and no `created_at`, so pinning today does not make a historical edge attributable to the thresholds that produced it. Adding those columns is the `edges`-table migration that left stores unopenable-forever in #1161, and historical reproduction stays explicitly out of scope. - **The injected block can now carry the evidence behind each belief, grouped by trust tier ([#1326](https://github.com/robotrocketscience/aelfrice/issues/1326), [#1177](https://github.com/robotrocketscience/aelfrice/issues/1177) proposal 18).** The per-turn line rendered `id`, `lock` and (since #1171) `speculative`, and threw away everything else the store knows about how far to trust a belief. `[hook] provenance_render` (default-**off**, `AELFRICE_PROVENANCE_RENDER` overrides) groups the block into `` / `` / `` with a framing clause each, and emits `origin`, `n` (= `alpha + beta`), `mu` and `seen` on non-locked lines. Every value is already on the belief at render time — measured, all four populated on **74 of 74** hits in a live pack — so there is no new query. The point is `n`: `mu = 0.6 at n = 2` is byte-identical to `mu = 0.6 at n = 200` at every scoring site, and one live pack carried **25 distinct `n` values from 1.6 to 363.2** inside a single turn's block, so the signal the ranker must collapse is one the model can weigh contextually. Section membership is a **total** function of `lock_level` and `origin` — the proposal as filed classified origins with two literal sets that between them stranded **6,396 active beliefs (14.3%)** in no section at all, and named two origins (`commit`, `file`) that do not exist; a renderer written to it would have dropped 14.3% of the block with no error. Every `models.ORIGIN_*` constant is now classified, an unrecognised origin falls back to `` rather than vanishing, and a test enumerates the constants from `models` so a new origin cannot be added without being classified. `speculative="1"` is folded into `origin="speculative"` rather than emitted alongside it, while the #1171 framing sentence still fires. With the flag off the block is byte-identical to before, asserted against literal expected bytes rather than recomputed. From 525ca86a73f59fca77458adce357e4bab1cf8a7b Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:33:38 -0700 Subject: [PATCH 12/15] refactor(detectors): narrow the sequence cast to the isinstance guard The string-literal cast to Sequence kept a TYPE_CHECKING import alive that CodeQL reads as dead (alert 557). The branch is already narrowed to tuple|list by the isinstance guard above it, so casting to that union is both stricter and self-contained. --- src/aelfrice/detector_thresholds.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/aelfrice/detector_thresholds.py b/src/aelfrice/detector_thresholds.py index df47fd6ec..41b3bec57 100644 --- a/src/aelfrice/detector_thresholds.py +++ b/src/aelfrice/detector_thresholds.py @@ -95,12 +95,7 @@ import json import re from dataclasses import dataclass, fields, is_dataclass -from typing import TYPE_CHECKING, Any, Final, cast - -if TYPE_CHECKING: # pragma: no cover - typing only - # Used only inside a string-literal `cast`, so the name is never - # evaluated at runtime and importing it unconditionally reads as dead. - from collections.abc import Sequence +from typing import Any, Final, cast # Bump when any pinned value below changes, and append the new content # digest to DIGEST_HISTORY at the bottom of this module. The guard in @@ -158,7 +153,7 @@ def _canonical(obj: Any) -> Any: mapping = cast("dict[str, Any]", obj) return [[k, _canonical(v)] for k, v in sorted(mapping.items())] if isinstance(obj, (tuple, list)): - seq = cast("Sequence[Any]", obj) + seq = cast("tuple[Any, ...] | list[Any]", obj) return [_canonical(x) for x in seq] if isinstance(obj, (bool, int, float, str)) or obj is None: return obj From 4347d85d5198b2208f752fd922d215f553da5f2c Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:56:40 -0700 Subject: [PATCH 13/15] test(detectors): replace a tautological digest assert with a reachable one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assert MANIFEST_DIGEST == DIGEST_HISTORY[VERSION] compared DIGEST_HISTORY.get(VERSION, "") against DIGEST_HISTORY[VERSION] — true by definition of .get whenever the key exists, which the contiguity test already guarantees. It could never fail. The case the .get fallback exists for — a version bump with no appended row — reached the preceding subscript first and reported as a KeyError, the crash the fallback was added to avoid. Assert the constant resolved before subscripting anything, and compare against it thereafter. Mutation-checked both directions: bumping the version without a row now names the missing row; changing a pinned value without bumping still names the digest mismatch. --- tests/test_detector_thresholds_manifest_1355.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/test_detector_thresholds_manifest_1355.py b/tests/test_detector_thresholds_manifest_1355.py index 031ab97d5..e3e94141d 100644 --- a/tests/test_detector_thresholds_manifest_1355.py +++ b/tests/test_detector_thresholds_manifest_1355.py @@ -202,13 +202,23 @@ def test_manifest_digest_is_pinned() -> None: the honest limit of this mechanism — see the constant's comment. Only a merge-base check in CI is fully mechanical, and that is not built. """ - assert manifest_digest() == DIGEST_HISTORY[DETECTOR_THRESHOLDS_VERSION], ( + # Ordered deliberately. The module resolves MANIFEST_DIGEST with + # `DIGEST_HISTORY.get(VERSION, "")` so that a missing row does not take + # the import down with a KeyError — but subscripting DIGEST_HISTORY + # here would reintroduce exactly that KeyError and report the contract + # breach as a crash. Naming it first is what the `.get` is for. + assert MANIFEST_DIGEST, ( + f"MANIFEST_DIGEST resolved to the empty-string fallback: " + f"DIGEST_HISTORY has no row for version " + f"{DETECTOR_THRESHOLDS_VERSION}. APPEND one — do not rewrite an " + f"existing row." + ) + assert manifest_digest() == MANIFEST_DIGEST, ( f"pinned content does not match the digest recorded for version " f"{DETECTOR_THRESHOLDS_VERSION}. Revert the change, or bump " f"DETECTOR_THRESHOLDS_VERSION and APPEND a row to DIGEST_HISTORY. " f"Do not rewrite the existing row." ) - assert MANIFEST_DIGEST == DIGEST_HISTORY[DETECTOR_THRESHOLDS_VERSION] def test_digest_history_is_contiguous_and_complete() -> None: From cff7be8bb764ffbca2f25a059aa13476ef700c29 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:59:02 -0700 Subject: [PATCH 14/15] test(detectors): correct the digest comment and de-vacuum the scalar filter (#1355) Two fixes in the manifest test. The comment above the covered/excluded overlap assert said `manifest_digest()` 'covers the version and THRESHOLDS'. It does not -- the payload is content-only by design and `DETECTOR_THRESHOLDS_VERSION` does not appear in it. The version bump is forced by `DIGEST_HISTORY` being keyed by version. The comment's actual conclusion still holds, so only its stated reason was wrong -- wrong in a way that reads as verified. The scalar filter selected on the raw strings {'numeric_cutoff', 'cap', 'weight', 'literal'} while KINDS was already imported and the KIND_* constants exist. Renaming any KIND_* value made the filter match fewer entries and the guard go quietly vacuous rather than red: with KIND_CUTOFF renamed, the raw-string filter selects 6 of the 11 scalar entries it should. Selecting through the constants keeps all 11. --- .../test_detector_thresholds_manifest_1355.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/test_detector_thresholds_manifest_1355.py b/tests/test_detector_thresholds_manifest_1355.py index e3e94141d..32e40cc55 100644 --- a/tests/test_detector_thresholds_manifest_1355.py +++ b/tests/test_detector_thresholds_manifest_1355.py @@ -40,6 +40,10 @@ COVERED_WRITER_MODULES, DETECTOR_THRESHOLDS_VERSION, EXCLUDED_WRITERS, + KIND_CAP, + KIND_CUTOFF, + KIND_LITERAL, + KIND_WEIGHT, KINDS, DIGEST_HISTORY, MANIFEST_DIGEST, @@ -136,7 +140,15 @@ def test_scalar_entries_pin_a_literal_not_a_digest() -> None: shipped values"). """ for entry in THRESHOLDS: - if entry.size is None and entry.kind in {"numeric_cutoff", "cap", "weight", "literal"}: + # Selected through the KIND_* constants, not their literal strings: + # a rename would otherwise make this filter match nothing and the + # guard would go silently vacuous instead of red. + if entry.size is None and entry.kind in { + KIND_CUTOFF, + KIND_CAP, + KIND_WEIGHT, + KIND_LITERAL, + }: assert not entry.value.startswith("sha256:"), ( f"{entry.module}.{entry.name} is a scalar and must pin its " f"literal, not a digest" @@ -307,9 +319,12 @@ def test_covered_and_excluded_do_not_overlap() -> None: for module, reason in EXCLUDED_WRITERS: assert reason.strip(), f"{module} excluded without a reason" - # `manifest_digest()` covers the version and THRESHOLDS, not the two - # coverage lists, so moving a module from covered to excluded moves no - # digest and no version. Without this, that move is silent AND leaves the + # `manifest_digest()` is content-only -- it covers THRESHOLDS and NOT + # the version (see its docstring; `DETECTOR_THRESHOLDS_VERSION` does not + # appear in the payload). The version bump is forced by `DIGEST_HISTORY` + # being keyed by version, not by the digest covering it. Neither covers + # the two coverage lists, so moving a module from covered to excluded + # moves no digest and no version. Without this, that move is silent AND leaves the # manifest self-contradictory: `test_covered_modules_all_have_entries` # stops applying to the module while its entries still sit in THRESHOLDS # claiming to gate edges the exclusion says it does not decide. From ec25ee6598e35fbd146184b400ff71b06d36ed17 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:05:36 -0700 Subject: [PATCH 15/15] style(detectors): sort __all__ in the threshold manifest (#1355) `DIGEST_HISTORY` sat after `KINDS`. No behaviour change -- `__all__` only governs star-imports and the module has no star-importer. Taken on convention, not on the reason the review gave: there is no ruff configuration and no lint job in this repo, so RUF022 is not enforced and no gate was failing. 19 of the 22 modules on main that declare a multi-entry `__all__` keep it sorted, which is reason enough on its own. --- src/aelfrice/detector_thresholds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aelfrice/detector_thresholds.py b/src/aelfrice/detector_thresholds.py index 41b3bec57..947d8fd7a 100644 --- a/src/aelfrice/detector_thresholds.py +++ b/src/aelfrice/detector_thresholds.py @@ -742,9 +742,9 @@ def manifest_digest() -> str: __all__ = [ "COVERED_WRITER_MODULES", "DETECTOR_THRESHOLDS_VERSION", + "DIGEST_HISTORY", "EXCLUDED_WRITERS", "KINDS", - "DIGEST_HISTORY", "MANIFEST_DIGEST", "PinnedThreshold", "THRESHOLDS",