diff --git a/CHANGELOG/v4.md b/CHANGELOG/v4.md index 1b21369a6..816ef5ee9 100644 --- a/CHANGELOG/v4.md +++ b/CHANGELOG/v4.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The LLM consent sentinel survived `aelf uninstall`, so a reinstall never re-prompted ([#1186](https://github.com/robotrocketscience/aelfrice/issues/1186)).** #1173 made disposition artifact-complete for the *store* directory and deliberately left `~/.aelfrice/` alone, because `projects/` there holds every other project's belief corpus and a blanket sweep would destroy corpora the command was not asked to touch. The cost was that every package-written path in that directory outlived the uninstall — decisively the consent sentinel, which records that the user agreed to send belief content to the vendor API (scoped per data class since #1172). Purge the store, `pip uninstall`, reinstall a month later, and the surviving sentinel was still valid for the same model + major version: the gate passed and no prompt appeared. Consent outlived the uninstall that was meant to end the relationship — the #1172 defect class one layer out. `~/.aelfrice/` is now enumerated **by name** rather than swept, in three dispositions. *Install state* goes in every mode, `--keep-db` included, since each file records that a step already happened and a survivor makes a reinstall read a stale decision as current: the consent sentinel, the manifest-version and uv-migration stamps (previously the only two cleared), the temporal-spine backfill sentinel, the auto-install lock, the `claude-memory` reconcile sentinel, and `logs/hook-failures.log` (with `logs/` pruned once empty, never `rmtree`d — a stranger's file in there is left alone). *Captured data* — `telemetry.jsonl` and the legacy `transcripts/` — goes only under `--purge`/`--archive`, matching the store-directory contract, and is reported as kept under `--keep-db`. *Preserved* in every mode: `projects/`, `shared/` (read-only federation peers per #655 — another store's corpus by another name, which the issue's own inventory missed), `config.json`, and `opt-out-hooks.json`. Anything else found there is listed and never deleted, and both destructive gates now disclose the `~/.aelfrice/` paths alongside the store manifest before prompting, because deleting a path the manifest never named is the #1173 defect one directory over. The removal *and* keep lists are single-sourced against their owning modules' constants by agreement tests, the same pattern #1185 used for the store siblings; `auto_install` gained an `AUTO_INSTALL_LOCK_FILENAME` constant so the lock could be named rather than re-hardcoded. `dotdir_plan` (read-only, used for the disclosure) and `dispose_dotdir` take the home directory as a required argument, so no caller can sweep a developer's own `~/.aelfrice/` by omission, and the store artifact set is passed as `skip` so the two dispositions cannot double-report the same path on the non-git fallback, where `~/.aelfrice/` *is* the store directory. - **`aelf doctor --classify-orphans` sent stored belief content with no consent prompt ([#1172](https://github.com/robotrocketscience/aelfrice/issues/1172)).** The documented boundary is four gates, the fourth being a recorded consent sentinel. `check_gates` covers only the first three and leaves gate 4 to the caller; `aelf onboard` wired it, `--classify-orphans` did not — it hardcoded `enabled=True`, never read the sentinel, and transmitted the content of every orphan belief to the vendor API. With the `[onboard-llm]` extra installed and `ANTHROPIC_API_KEY` exported (both common), a *diagnostic* subcommand shipped the memory store outbound with no prompt, and because no sentinel was ever written, `aelf doctor revoke-llm-consent` could not prevent it. Consent is now **scoped**: a sentinel records which data classes the accepted prompt disclosed. `aelf onboard` grants `onboard_candidates`; `--classify-orphans` requires `stored_beliefs` and prompts separately with a disclosure naming stored belief content — the onboard text never did, since it enumerates document sentences, commit subjects and docstrings and promises "nothing outside the extracted candidate text". Pre-existing sentinels have no `scopes` key and are read as onboard-only, so they do not grandfather this path. Granting either scope preserves the other. `--dry-run` still previews the candidate set with no gate check and no network call. - **Replay re-derived every transcript belief with the wrong origin and a 5× deflated prior ([#1167](https://github.com/robotrocketscience/aelfrice/issues/1167)).** `replay_full_equality` built its `DerivationInput` with `raw_meta=None`, under a comment that had been false since [#888](https://github.com/robotrocketscience/aelfrice/issues/888): `derive()` reads `raw_meta["role"]` to route `source_kind=transcript` user turns to the undeflated user-source prior and `origin=user_transcript`. Transcript ingest is the dominant production write path, so every belief it produced re-derived as `agent_inferred` at 1/5 the alpha — `aelf doctor --replay` reported drift on any healthy store and its drift signal was pure noise. The log already persists `raw_meta` verbatim, so passing it through makes the round-trip exact. **The probe was also widened.** It compared 3 of ~21 belief columns and dropped the rest silently, which is why the alpha half of this divergence was invisible while its origin half was flagged: a new `mutable_divergence` counter plus a per-field breakdown over `alpha`, `beta`, `lock_level`, `retention_class`, `scope`, `last_retrieved_at` and the edge set is now reported by `aelf doctor --replay` and recorded in the soak history. Those fields stay **informational, never drift** — each is legitimately rewritten after ingest (feedback, `aelf lock`, the snapshot lifecycle, federation, retrieval stamping) and none is reconstructible from the write log as it stands, so counting them as drift would fire on every live store; they can be promoted into the strict contract once the log is total ([#1157](https://github.com/robotrocketscience/aelfrice/issues/1157)). The replay-soak corpus, whose 76 rows all carried `raw_meta: null` and so could not observe any metadata-reading branch, gains four `raw_meta` rows covering the user-transcript path and its two negative cases. +- **Valence propagation attenuated by the wrong belief, double-counted fan-in, and depended on edge insertion order ([#1169](https://github.com/robotrocketscience/aelfrice/issues/1169)).** Four compounding defects in `store.propagate_valence`, the BFS a feedback event walks to reach related beliefs. (1) **Attenuation direction.** The multiplier was the *recipient's* posterior while the docstring called it broker confidence, so the evidence a belief received was proportional to how confident it already was — rich-get-richer on positive signal, and low-confidence junk structurally shielded from negative signal because its small factor pushed the delta under `min_threshold` (a plausible mechanism for the recorded junk-percolation ranking inversion). It now uses the confidence of the belief the signal travels *through*, and `apply_feedback` supplies the source's confidence as of *before* the event so an event cannot amplify its own propagation. (2) **Fan-in and cycles.** Delivery accumulated per in-edge, outside the `visited` guard: a diamond delivered 2×, a 5-way fan-in 5×, so one `aelf confirm` on the root of a convergent subgraph could add α += 20 at the convergence point — and because the source guard covered only the source, any bidirectional edge pair between two non-source beliefs re-delivered to them on every event. Each belief is now credited exactly once per walk, at the shallowest hop that reaches it, by the strongest path within that hop — shortest-path-wins rather than largest-magnitude-wins, since a longer chain of strong edges can carry more than a short weak one and the shallower path is still the one taken. (3) **Determinism.** `edges_from` had no `ORDER BY`, so row order was physical insertion order and the delta delivered to a node could differ 3.3× purely from edge insertion sequence — a determinism leak on a default-on path that `VACUUM` or a migration would move. `edges_from` (and its peer-scope sibling) now order by `(dst, type)`, and within a hop candidates rank by `(-abs(delta), dst)`. (4) **Unbounded mass.** Total injected evidence per event is now capped, defaulting to `abs(valence) * max_hops`, taking the strongest paths first. A new hypothesis property test asserts, for any graph shape, that mass stays within budget, the source is never a recipient, no single delta exceeds the source valence, and the output is invariant to edge insertion order. - **Codex `$aelf-onboard` classifies on a low-tier model, not the session model ([#1153](https://github.com/robotrocketscience/aelfrice/issues/1153)).** onboard's bulk sentence-classification fan-out defaults to a low-cost model tier (tier-abstract since [#1155](https://github.com/robotrocketscience/aelfrice/issues/1155)). The Codex skill generator carried the slash body verbatim with only a generic "use Codex's own subagent mechanism" note, so on Codex a bare "low-cost tier" default had nothing concrete to resolve to and the work fell through to the session's default model (e.g. `gpt-5.6-luna`): the expensive, slow path for short-label classification. `codex_skill_from_slash` now appends, for the onboard skill, an adapter note resolving that default to Codex's cheapest fast tier (a `-mini`-class model) rather than the session default, without pinning a model id (names drift). Deterministic transform; the note is onboard-specific, so subagent skills with no model directive (reason/wonder) don't carry it. Surfaced during the #1056 live validation. - **Codex host doctor/setup no longer report the `hooks` feature as off ([#1151](https://github.com/robotrocketscience/aelfrice/issues/1151)).** `host_codex.py` probed a retired Codex feature-flag name (`codex_hooks`) and, because a default-on feature is absent from `config.toml`, reported it "off" — so `aelf doctor --host codex` and `aelf setup --host codex` warned that Codex would run no hooks (and printed `codex features enable codex_hooks`, which errors) even while the hooks demonstrably fire. Surfaced by the live #1056 validation on Codex 0.145.0. The probe now keys on the current `hooks` feature (stable, on by default since the 0.11x–0.12x `codex_hooks` under-development flag was promoted), treats an unmentioned feature as its default (on) rather than off, still honours an explicit legacy `codex_hooks = true`, and warns only when `[features].hooks = false` is set explicitly. Setup guidance names `hooks` and the `--dangerously-bypass-hook-trust` automation path. Doc/message + detection only; hook wiring unchanged. - **The nightly reproducibility band-check reported success when it had measured nothing ([#1160](https://github.com/robotrocketscience/aelfrice/issues/1160)).** `tolerance.summarize` returned PASS whenever no leaf was FAIL or WARN. Ignoring an *individual* SKIP is correct and ratified (#479) — one uncomputable metric is not a regression — but the rollup ignored *every* SKIP, so a run in which nothing could be computed rolled up to PASS. That is precisely the shape a failed dataset download on the runner takes: every adapter exits because its data dir is absent, every leaf becomes SKIP, counts read `{pass: 0, warn: 0, fail: 0, skip: 12}`, and the verdict was PASS. An empty check list returned PASS as well, and `bench-canonical.yml` exited 0 on anything that was not `fail`, so the cron went green having compared nothing at all. PASS is a claim that something was measured and stayed in band, so it now requires at least one leaf that actually passed; otherwise the rollup is a new `NO_DATA` verdict, and the workflow exits 1 on it. #479's behaviour is preserved exactly — a SKIP beside any real PASS still rolls up to PASS, and FAIL and WARN still dominate, because a leaf that was compared to its band is evidence that measurement happened (so WARN with no PASS stays WARN, not `NO_DATA`). `NO_DATA` is a distinct verdict rather than reusing FAIL because the two demand different responses: a regression means read the diff, no data means fix the runner. Leaf tallying also moved to `.get` so a leaf carrying an unexpected verdict is counted and reported instead of raising `KeyError` inside the gate whose job is to report it. **Not covered:** the same acceptance criterion also asks for one-sided bands so that an improvement is not classified as a failure. That needs a per-metric direction table — quality metrics improve upward, latency metrics downward — and guessing a direction would make the gate blind to regressions in the wrong direction, which is the same class of defect this work exists to remove. Left for a deliberate decision rather than inferred from metric names. diff --git a/src/aelfrice/feedback.py b/src/aelfrice/feedback.py index 174a12343..b0862f8ab 100644 --- a/src/aelfrice/feedback.py +++ b/src/aelfrice/feedback.py @@ -162,7 +162,18 @@ def apply_feedback( ) if update_posterior and propagate and _propagation_enabled(): - deltas = store.propagate_valence(belief_id, valence) + # #1169: the first hop is attenuated by the source's confidence as + # it was *before* this event. Letting propagate_valence read the + # row back would fold this event's own increment into the strength + # of its own propagation. + prior_denom = prior_alpha + prior_beta + deltas = store.propagate_valence( + belief_id, + valence, + src_confidence=( + (prior_alpha / prior_denom) if prior_denom > 0 else 0.0 + ), + ) # Sorted for a deterministic feedback_history row order # regardless of edge-iteration order inside the BFS. for dst_id, delta in sorted(deltas.items()): diff --git a/src/aelfrice/store.py b/src/aelfrice/store.py index d3c881e76..9efc2ca0e 100644 --- a/src/aelfrice/store.py +++ b/src/aelfrice/store.py @@ -5228,8 +5228,23 @@ def delete_edges_by_type(self, type_: str) -> int: return removed def edges_from(self, src: str) -> list[Edge]: + """Outbound edges of `src`, in a total order. + + `ORDER BY dst, type` (#1169). Without it the row order is + `(src, rowid)` — physical insertion order, which SQLite does not + contract and which `VACUUM`, a migration, or a different query + plan will change. `propagate_valence` walks this list, so an + unordered read made the posterior deltas it produces a function + of storage layout rather than of the write log. + + The order is free: `edges` is `PRIMARY KEY (src, dst, type)`, so + its autoindex already yields that sequence under `WHERE src = ?`. + `EXPLAIN QUERY PLAN` shows a plain index search with no temp + b-tree even at ~600 outbound edges from one belief — no extra + index is needed to keep this O(k) in the outgoing degree. + """ cur = self._conn.execute( - "SELECT * FROM edges WHERE src = ?", (src,) + "SELECT * FROM edges WHERE src = ? ORDER BY dst, type", (src,) ) return [_row_to_edge(r) for r in cur.fetchall()] @@ -5305,7 +5320,7 @@ def edges_from_in_scope( return [] try: cur = conn.execute( - "SELECT * FROM edges WHERE src = ?", (src,) + "SELECT * FROM edges WHERE src = ? ORDER BY dst, type", (src,) ) return [_row_to_edge(r) for r in cur.fetchall()] except sqlite3.OperationalError: @@ -5524,52 +5539,149 @@ def propagate_valence( valence: float, max_hops: int = 3, min_threshold: float = 0.05, + max_total_mass: float | None = None, + src_confidence: float | None = None, ) -> dict[str, float]: """Propagate a valence signal outward through edges, attenuated by - broker confidence (alpha / (alpha + beta) of intermediate beliefs). - - BFS over outbound edges. At each hop the carried magnitude is - multiplied by: - EDGE_VALENCE[edge.type] * broker_confidence(dst) - Stops when |carried| < min_threshold or hop count exceeds max_hops. - - Returns: dict mapping touched belief_id -> sum of applied deltas. - The src_id itself is NOT included in the returned map (it's the - source, not a recipient) — a cycle back into the source is - dropped rather than delivered, so the signal can never feed - back into the belief that emitted it (#1058). + broker confidence. + + BFS over outbound edges. For a hop out of belief `b`, the carried + magnitude is multiplied by:: + + EDGE_VALENCE[edge.type] * confidence(b) + + where ``confidence(b) = b.alpha / (b.alpha + b.beta)`` — the + *broker*, the belief the signal is travelling through. Before + #1169 this multiplied by the confidence of the belief being + *written to*, which meant the amount of evidence a belief + received was proportional to how confident it already was: + high-posterior beliefs absorbed the most positive signal, and + low-confidence beliefs were structurally shielded from negative + feedback because their small factor pushed the delta under + `min_threshold`. That is a rich-get-richer loop with no + evidential justification, and a candidate mechanism for the + recorded junk-percolation ranking inversion. + + Each reachable belief is credited **exactly once** per walk, at + the shallowest hop that reaches it, by the strongest path within + that hop. Shortest-path-wins, not largest-magnitude-wins: a + longer chain of strong edges can carry more than a short weak + one (`A -RELATES_TO(0.3)-> X` delivers 0.27 while + `A -SUPPORTS-> B -SUPPORTS-> X` would deliver 0.81), and the + shallower path is the one taken. Magnitude is non-increasing + *along* a path, since every `EDGE_VALENCE` magnitude and every + confidence is <= 1 — but that says nothing about two different + paths of different lengths, so hop count is the tie-break in + practice. Deliberate: crediting by the maximum over all paths + would mean deferring every delivery until the walk completes, + and hop distance is itself evidence of relatedness. Accumulating per + in-edge (the pre-#1169 behaviour) meant a diamond delivered 2x + and a 5-way fan-in 5x the source signal — one `aelf confirm` on + the root of a convergent subgraph could add α += ~20 to the + node at the convergence point. It also let a cycle between two + non-source beliefs re-deliver to them on every event. + + Determinism: `edges_from` is totally ordered, and within a hop + candidates are ranked by `(-abs(delta), dst)` so the strongest + path wins and ties break on id. The result is a function of the + graph, not of edge insertion order or physical layout. + + Stops when |carried| < `min_threshold`, when the hop count + exceeds `max_hops`, or when the mass budget is exhausted. + + `max_total_mass` bounds the total absolute evidence one event can + inject, defaulting to ``abs(valence) * max_hops``. Candidates are + considered strongest-first, so when the budget binds it is the + weakest paths that are dropped; the delivery that straddles the + boundary is clipped to what remains rather than skipped, so an + explicitly small budget scales the walk down instead of + cancelling it. Without a cap, total injected mass grows with + graph size — a property of the substrate rather than of the + user's single click. + + `src_confidence` overrides the broker factor for the first hop. + `apply_feedback` passes the source's confidence as it was *before* + the event, because that is the trust the inference was judged + against; reading it back off the row would fold the event's own + increment into the strength of its own propagation, which is the + self-reinforcement #1058 set out to remove. + + Returns: dict mapping touched belief_id -> applied delta. The + `src_id` itself is NOT included (it's the source, not a + recipient) — a cycle back into the source is dropped rather than + delivered, so the signal can never feed back into the belief + that emitted it (#1058). """ + cap = ( + abs(valence) * max_hops if max_total_mass is None + else abs(max_total_mass) + ) applied: dict[str, float] = {} + mass_used = 0.0 # Frontier entries: (belief_id, magnitude_carried_into_it, hops_taken) # The source contributes its outbound edges at hop 1. frontier: list[tuple[str, float, int]] = [(src_id, valence, 0)] visited: set[str] = {src_id} while frontier: - next_frontier: list[tuple[str, float, int]] = [] + # Collect this hop's candidate deliveries, then resolve them + # together: a belief reachable by several paths in the same + # hop must be credited once, by its strongest path. Across + # hops the earlier (shallower) delivery wins — see the + # shortest-path-wins note in the method docstring. + candidates: list[tuple[str, float, int]] = [] for current_id, carried, hops in frontier: if hops >= max_hops: continue if abs(carried) < min_threshold: continue + if current_id == src_id and src_confidence is not None: + broker = src_confidence + else: + broker_belief = self.get_belief(current_id) + if broker_belief is None: + continue + broker_denom = broker_belief.alpha + broker_belief.beta + broker = ( + (broker_belief.alpha / broker_denom) + if broker_denom > 0 else 0.0 + ) for edge in self.edges_from(current_id): - if edge.dst == src_id: + if edge.dst == src_id or edge.dst in visited: continue multiplier = EDGE_VALENCE.get(edge.type, 0.0) if multiplier == 0.0: continue - dst = self.get_belief(edge.dst) - if dst is None: + if self.get_belief(edge.dst) is None: continue - denom = dst.alpha + dst.beta - broker = (dst.alpha / denom) if denom > 0 else 0.0 delta = carried * multiplier * broker if abs(delta) < min_threshold: continue - applied[edge.dst] = applied.get(edge.dst, 0.0) + delta - if edge.dst not in visited: - visited.add(edge.dst) - next_frontier.append((edge.dst, delta, hops + 1)) + candidates.append((edge.dst, delta, hops + 1)) + + # Strongest path first; `dst` breaks ties into a total order. + candidates.sort(key=lambda c: (-abs(c[1]), c[0])) + + next_frontier: list[tuple[str, float, int]] = [] + for dst_id, delta, next_hops in candidates: + if dst_id in visited: + continue + remaining = cap - mass_used + if remaining <= 0.0: + break + if abs(delta) > remaining: + # Clip to the budget rather than dropping the delivery. + # Skipping instead would make a cap below the first + # delta swallow the entire event — with the default cap + # the two are identical, because a single delta can + # never exceed abs(valence) <= abs(valence) * max_hops. + delta = remaining if delta > 0.0 else -remaining + if abs(delta) < min_threshold: + break + visited.add(dst_id) + mass_used += abs(delta) + applied[dst_id] = delta + next_frontier.append((dst_id, delta, next_hops)) frontier = next_frontier return applied diff --git a/tests/test_propagate_valence.py b/tests/test_propagate_valence.py index 22e9e37de..6a8ca9e8a 100644 --- a/tests/test_propagate_valence.py +++ b/tests/test_propagate_valence.py @@ -1,13 +1,26 @@ -"""Setr broker-confidence attenuation test. +"""`MemoryStore.propagate_valence` — attenuation, crediting, determinism. -A->B->C chain. B is the broker. Low-confidence B should dampen propagation -into C by ~9x compared to high-confidence B. +Broker attenuation (#1058): an A->B->C chain where B is the broker. A +low-confidence B should dampen propagation into C by ~9x relative to a +high-confidence B. + +Correctness of the walk (#1169): the multiplier is the confidence of the +belief the signal travels *through*, not of the belief being written to; +each belief is credited exactly once per walk regardless of fan-in, +reconvergence, or cycles; total injected mass is capped; and the output is +a function of the graph rather than of edge insertion order. """ from __future__ import annotations +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + from aelfrice.models import ( BELIEF_FACTUAL, + EDGE_CITES, EDGE_CONTRADICTS, + EDGE_RELATES_TO, EDGE_SUPPORTS, LOCK_NONE, Belief, @@ -33,12 +46,12 @@ def _mk(bid: str, alpha: float, beta: float) -> Belief: def _build_chain(broker_alpha: float, broker_beta: float) -> MemoryStore: s = MemoryStore(":memory:") - # A is source; broker confidence at A doesn't matter for downstream - # because propagation multiplier uses dst broker, but give it neutral. + # A is the source. Its own confidence scales the first hop (the + # multiplier is the confidence of the belief the signal travels + # *through*, #1169), so keep it neutral and identical across runs. s.insert_belief(_mk("A", alpha=5.0, beta=5.0)) + # B is the broker for the B->C hop; this is the factor under test. s.insert_belief(_mk("B", alpha=broker_alpha, beta=broker_beta)) - # C is the terminus; give it neutral confidence so its broker factor - # is the same constant in both runs. s.insert_belief(_mk("C", alpha=5.0, beta=5.0)) s.insert_edge(Edge(src="A", dst="B", type=EDGE_SUPPORTS, weight=1.0)) s.insert_edge(Edge(src="B", dst="C", type=EDGE_SUPPORTS, weight=1.0)) @@ -119,15 +132,12 @@ def test_contradicts_chain_flips_then_restores_sign() -> None: assert out["Y"] > 0.0 -def test_reconvergent_paths_accumulate_delivery_once_onward() -> None: - """Characterization: diamond A->{B1,B2}->C->D. +def test_reconvergent_paths_credit_each_belief_once() -> None: + """Diamond A->{B1,B2}->C->D credits C once, not twice (#1169). - Delivery into C accumulates across both paths, but C's onward - propagation carries only the first-arriving delta (first-visit - frontier semantics). D therefore sees half of what naive - both-paths propagation would give. Documented as-is in #1058; - changing it would change delta magnitudes downstream. - """ + Before #1169 delivery accumulated per in-edge, outside the visited + guard, so C received 2x the mass of a single path. Falsifiable by + out["C"] coming back at 0.5.""" s = MemoryStore(":memory:") for bid in ("A", "B1", "B2", "C", "D"): s.insert_belief(_mk(bid, alpha=5.0, beta=5.0)) @@ -136,12 +146,196 @@ def test_reconvergent_paths_accumulate_delivery_once_onward() -> None: s.insert_edge(Edge(src=src, dst=dst, type=EDGE_SUPPORTS, weight=1.0)) out = s.propagate_valence("A", valence=1.0, max_hops=4, min_threshold=0.0001) - # Both paths deliver 0.25 into C (1.0 * 1.0 * 0.5 broker, twice over - # two hops); onward flow into D uses the first 0.25 only. - assert abs(out["C"] - 0.5) < 1e-9 + # Every belief here is (5, 5) so every broker factor is 0.5 and every + # SUPPORTS multiplier is 1.0: each hop halves the carried magnitude. + assert abs(out["B1"] - 0.5) < 1e-9 + assert abs(out["B2"] - 0.5) < 1e-9 + assert abs(out["C"] - 0.25) < 1e-9 assert abs(out["D"] - 0.125) < 1e-9 +def test_fan_in_does_not_multiply_the_source_signal() -> None: + """A 5-way fan-in credits the hub once, not 5x (#1169). + + The issue's worked example: one `aelf confirm` on the root of a + convergent subgraph added alpha += 5.0 to the convergence point. + Falsifiable by out["T"] exceeding a single path's magnitude.""" + s = MemoryStore(":memory:") + for bid in ("A", "T", *[f"B{i}" for i in range(5)]): + s.insert_belief(_mk(bid, alpha=5.0, beta=5.0)) + for i in range(5): + s.insert_edge( + Edge(src="A", dst=f"B{i}", type=EDGE_SUPPORTS, weight=1.0) + ) + s.insert_edge( + Edge(src=f"B{i}", dst="T", type=EDGE_SUPPORTS, weight=1.0) + ) + out = s.propagate_valence("A", valence=1.0, max_hops=3, + min_threshold=0.0001) + assert abs(out["T"] - 0.25) < 1e-9, f"hub over-credited: {out}" + + +def test_cycle_between_non_source_beliefs_delivers_once() -> None: + """A->B, B->C, C->B credits B once (#1169). + + The pre-#1169 source guard covered only the source, so a back-edge + into an already-visited non-source belief re-delivered to it — B came + back at 2.0x. Any bidirectional RELATES_TO or CONTRADICTS pair inside + the hop radius hit this. Falsifiable by B exceeding one path.""" + s = MemoryStore(":memory:") + for bid in ("A", "B", "C"): + s.insert_belief(_mk(bid, alpha=5.0, beta=5.0)) + for src, dst in (("A", "B"), ("B", "C"), ("C", "B")): + s.insert_edge(Edge(src=src, dst=dst, type=EDGE_SUPPORTS, weight=1.0)) + out = s.propagate_valence("A", valence=1.0, max_hops=5, + min_threshold=0.0001) + assert abs(out["B"] - 0.5) < 1e-9, f"B re-credited by the cycle: {out}" + assert abs(out["C"] - 0.25) < 1e-9 + + +# --------------------------------------------------------------------------- +# Attenuation direction (#1169) +# --------------------------------------------------------------------------- + + +def test_recipient_confidence_does_not_scale_its_own_delta() -> None: + """The delta a belief receives is independent of its own posterior. + + This is the rich-get-richer defect: attenuating by the *recipient's* + confidence meant a belief at mu=0.95 absorbed 0.95x the signal while + one at mu=0.10 absorbed 0.10x, widening the gap with no evidence + about either. Falsifiable by the two deltas differing.""" + def one_hop(dst_alpha: float, dst_beta: float) -> float: + s = MemoryStore(":memory:") + s.insert_belief(_mk("A", alpha=5.0, beta=5.0)) + s.insert_belief(_mk("B", alpha=dst_alpha, beta=dst_beta)) + s.insert_edge(Edge(src="A", dst="B", type=EDGE_SUPPORTS, weight=1.0)) + out = s.propagate_valence("A", valence=1.0, max_hops=1, + min_threshold=0.0001) + return out["B"] + + confident = one_hop(9.0, 1.0) # mu = 0.9 + doubtful = one_hop(1.0, 9.0) # mu = 0.1 + assert abs(confident - doubtful) < 1e-9, ( + f"recipient posterior still scales its own delta: " + f"{confident} vs {doubtful}" + ) + + +def test_low_confidence_belief_still_receives_negative_feedback() -> None: + """A junk belief is not shielded from a negative signal (#1169). + + With recipient-side attenuation, valence -1.0 into a belief at + mu=0.08 became -0.08 and fell under the default min_threshold of + 0.05 once any edge multiplier was below 0.625 — low-confidence junk + was structurally immune to the signal meant to remove it. + Falsifiable by "J" being absent from the result.""" + s = MemoryStore(":memory:") + s.insert_belief(_mk("A", alpha=9.0, beta=1.0)) + s.insert_belief(_mk("J", alpha=0.5, beta=6.0)) # mu ~= 0.077 + s.insert_edge(Edge(src="A", dst="J", type=EDGE_CITES, weight=1.0)) + out = s.propagate_valence("A", valence=-1.0, max_hops=1) + assert "J" in out, f"junk shielded from negative feedback: {out}" + assert out["J"] < 0.0 + + +# --------------------------------------------------------------------------- +# Determinism and the mass cap (#1169) +# --------------------------------------------------------------------------- + + +def _diamond_in_edge_order( + edge_order: list[tuple[str, str, str]], +) -> dict[str, float]: + s = MemoryStore(":memory:") + for bid in ("A", "B", "C", "D", "E"): + s.insert_belief(_mk(bid, alpha=5.0, beta=5.0)) + for src, dst, etype in edge_order: + s.insert_edge(Edge(src=src, dst=dst, type=etype, weight=1.0)) + return s.propagate_valence("A", valence=1.0, max_hops=4, + min_threshold=0.0001) + + +def test_output_is_invariant_to_edge_insertion_order() -> None: + """Same logical graph, different physical row order, same deltas. + + `edges_from` had no ORDER BY, so row order was (src, rowid) — + insertion order. The issue measured a 3.3x difference in the delta + delivered to E purely from inserting A-SUPPORTS->B before or after + A-RELATES_TO->C. Falsifiable by the two dicts differing.""" + from aelfrice.models import EDGE_RELATES_TO + + forward = [ + ("A", "B", EDGE_SUPPORTS), + ("A", "C", EDGE_RELATES_TO), + ("B", "D", EDGE_SUPPORTS), + ("C", "D", EDGE_SUPPORTS), + ("D", "E", EDGE_SUPPORTS), + ] + reversed_order = list(reversed(forward)) + + assert _diamond_in_edge_order(forward) == _diamond_in_edge_order( + reversed_order + ) + + +def test_total_injected_mass_is_capped( # AC4 +) -> None: + """One event cannot inject unbounded evidence into a wide graph. + + Falsifiable by the summed absolute delta exceeding the cap.""" + s = MemoryStore(":memory:") + s.insert_belief(_mk("A", alpha=9.0, beta=1.0)) + for i in range(40): + s.insert_belief(_mk(f"B{i:02d}", alpha=5.0, beta=5.0)) + s.insert_edge( + Edge(src="A", dst=f"B{i:02d}", type=EDGE_SUPPORTS, weight=1.0) + ) + out = s.propagate_valence("A", valence=1.0, max_hops=3, + min_threshold=0.0001) + total = sum(abs(v) for v in out.values()) + assert total <= 1.0 * 3 + 1e-9, f"mass {total} exceeds the cap" + # The cap binds here, so not every neighbour is reached — but the ones + # that are still get a full-strength delta rather than a diluted one. + assert out, "cap swallowed every delivery" + assert all(abs(v) > 0.0 for v in out.values()) + + +def test_explicit_max_total_mass_is_honoured_for_negative_valence() -> None: + """The override binds on negative events as well as positive ones. + + A budget below abs(valence) * max_hops must clip the walk; the deltas + that survive stay negative. Falsifiable by the summed magnitude + exceeding the budget or by a positive delta appearing.""" + s = MemoryStore(":memory:") + s.insert_belief(_mk("A", alpha=9.0, beta=1.0)) + for i in range(10): + s.insert_belief(_mk(f"B{i}", alpha=5.0, beta=5.0)) + s.insert_edge( + Edge(src="A", dst=f"B{i}", type=EDGE_SUPPORTS, weight=1.0) + ) + out = s.propagate_valence("A", valence=-1.0, max_hops=3, + min_threshold=0.0001, max_total_mass=0.5) + assert out, "budget swallowed every delivery" + assert sum(abs(v) for v in out.values()) <= 0.5 + 1e-9 + assert all(v < 0.0 for v in out.values()) + + +def test_explicit_max_total_mass_is_honoured() -> None: + """An explicit budget overrides the default. Falsifiable by the sum + exceeding the passed value.""" + s = MemoryStore(":memory:") + s.insert_belief(_mk("A", alpha=9.0, beta=1.0)) + for i in range(10): + s.insert_belief(_mk(f"B{i}", alpha=5.0, beta=5.0)) + s.insert_edge( + Edge(src="A", dst=f"B{i}", type=EDGE_SUPPORTS, weight=1.0) + ) + out = s.propagate_valence("A", valence=1.0, max_hops=3, + min_threshold=0.0001, max_total_mass=1.0) + assert sum(abs(v) for v in out.values()) <= 1.0 + 1e-9 + + def test_min_threshold_prunes_weak_deltas() -> None: """A delta whose magnitude falls below min_threshold is not delivered and does not extend the frontier.""" @@ -152,3 +346,123 @@ def test_min_threshold_prunes_weak_deltas() -> None: min_threshold=0.3) assert "B" in out assert "C" not in out + + +# --------------------------------------------------------------------------- +# Property test (#1169 AC5): mass is bounded and order-invariant for any +# graph shape hypothesis can build. +# --------------------------------------------------------------------------- + +_N_NODES = 6 +_NODE_IDS = [f"n{i}" for i in range(_N_NODES)] + + +def _store_from_edges( + edges: list[tuple[int, int]], shuffle_seed: int, +) -> MemoryStore: + s = MemoryStore(":memory:") + for bid in _NODE_IDS: + s.insert_belief(_mk(bid, alpha=5.0, beta=5.0)) + # Rotate the insertion order so physical row order differs between + # the two stores built from the same logical edge set. + ordered = edges[shuffle_seed:] + edges[:shuffle_seed] + for src, dst in ordered: + s.insert_edge( + Edge(src=_NODE_IDS[src], dst=_NODE_IDS[dst], + type=EDGE_SUPPORTS, weight=1.0) + ) + return s + + +@given( + edges=st.lists( + # Self-loops are NOT filtered: `insert_edge` accepts src == dst + # (no guard, and the PK permits it), so a real store can hold one + # and the walk must handle it — a self-loop on the source is + # dropped by the source guard, one elsewhere by the visited set. + st.tuples( + st.integers(min_value=0, max_value=_N_NODES - 1), + st.integers(min_value=0, max_value=_N_NODES - 1), + ), + min_size=0, + max_size=14, + unique=True, + ), + valence=st.sampled_from([1.0, -1.0, 0.5]), + max_hops=st.integers(min_value=1, max_value=4), +) +@settings(max_examples=150, deadline=None) +def test_mass_bounded_and_order_invariant_for_any_shape( + edges: list[tuple[int, int]], valence: float, max_hops: int, +) -> None: + """For any graph shape: total injected mass stays within the budget, + the source is never a recipient, and the result does not depend on + edge insertion order. + + Falsifiable by a mass overrun (the fan-in/diamond/cycle amplification + classes), by the source appearing, or by the two orderings + disagreeing.""" + base = _store_from_edges(edges, shuffle_seed=0) + rotated = _store_from_edges(edges, shuffle_seed=len(edges) // 2) + try: + out = base.propagate_valence( + _NODE_IDS[0], valence=valence, max_hops=max_hops, + min_threshold=0.0001, + ) + out_rotated = rotated.propagate_valence( + _NODE_IDS[0], valence=valence, max_hops=max_hops, + min_threshold=0.0001, + ) + finally: + base.close() + rotated.close() + + cap = abs(valence) * max_hops + assert sum(abs(v) for v in out.values()) <= cap + 1e-9 + assert _NODE_IDS[0] not in out + # Each belief credited at most once, so no delta can exceed the + # strongest single hop out of the source. + assert all(abs(v) <= abs(valence) + 1e-9 for v in out.values()) + assert out == pytest.approx(out_rotated) + + +def test_the_shallower_path_wins_even_when_a_longer_one_is_stronger() -> None: + """Hypothesis: credit lands at the shallowest hop, not the largest path. + + Pinned because the two are easy to conflate. Magnitude is + non-increasing *along* a path (every EDGE_VALENCE magnitude and every + confidence is <= 1), which invites the reading that the first path to + arrive is also the strongest. It is not: a longer chain of strong + edges can carry more than a short weak one, and the shallower path is + still the one taken. + + Falsifiable by X receiving the two-hop magnitude instead of the + one-hop one.""" + s = MemoryStore(":memory:") + try: + s.insert_belief(_mk("A", alpha=9.0, beta=1.0)) # confidence 0.9 + s.insert_belief(_mk("B", alpha=9.0, beta=1.0)) # confidence 0.9 + s.insert_belief(_mk("X", alpha=5.0, beta=5.0)) + # Short and weak: RELATES_TO carries 0.3. + s.insert_edge(Edge(src="A", dst="X", type=EDGE_RELATES_TO, weight=1.0)) + # Long and strong: two SUPPORTS hops, each carrying 1.0. + s.insert_edge(Edge(src="A", dst="B", type=EDGE_SUPPORTS, weight=1.0)) + s.insert_edge(Edge(src="B", dst="X", type=EDGE_SUPPORTS, weight=1.0)) + + out = s.propagate_valence( + "A", valence=1.0, max_hops=3, min_threshold=0.0001, + src_confidence=0.9, + ) + finally: + s.close() + + one_hop = 1.0 * 0.3 * 0.9 # EDGE_VALENCE[RELATES_TO] * conf(A) + two_hop = (1.0 * 1.0 * 0.9) * 1.0 * 0.9 # via B, strictly larger + assert two_hop > one_hop, "fixture no longer demonstrates the case" + + assert out["X"] == pytest.approx(one_hop), ( + f"X took the {'two-hop' if out['X'] == pytest.approx(two_hop) else 'wrong'} " + f"path; credit is documented as landing at the shallowest hop" + ) + # And B, reached only the one way, is unaffected by the tie-break. + assert out["B"] == pytest.approx(1.0 * 1.0 * 0.9)