feat(adr-0083): transitive (multi-hop) teaching-grounded surface (#63)
Strict superset of ADR-0062's depth-1 composer. `max_depth` is the
number of follow-up hops appended beyond the initial chain:
max_depth=0 → byte-identical to single-chain surface
max_depth=1 → byte-identical to ADR-0062 composed
max_depth=2 → byte-identical to ADR-0062 when no second hop
survives, strict superset when one does
The composer surfaces content the realizer was silently dropping
from chains already ratified in `cognition_chains_v1`. Example
live lift on `"Why does light exist?"`:
composed: "light reveals truth, which grounds knowledge."
transitive(2): "...which grounds knowledge, which requires evidence."
Cycle-safe at every depth via a single visited-set; single-corpus
traversal in v1 (cross-corpus transitive deferred to a follow-up
ADR alongside ADR-0064's cross-pack model).
Both flags default False — every existing surface is preserved
byte-identically. When both `composed_surface` and
`transitive_surface` are True, transitive wins.
Implementation:
- `core/config.py`: `transitive_surface: bool = False`,
`transitive_max_depth: int = 2`.
- `chat/teaching_grounding.py`: `_resolve_followup` shared helper
refactored out of the depth-1 composer (no behavioural change),
plus new `teaching_grounded_surface_transitive(subject,
intent_tag, *, max_depth)`.
- `chat/runtime.py`: dispatch order — transitive > composed > single.
Verification:
- tests/test_transitive_surface.py: 16 new tests covering pure-fn
contract, visited-set cycle guard at every depth, runtime
integration, and the cognition-lane null-drop invariant at
`max_depth=2` (public + holdout splits).
- tests/test_composed_surface.py: 11/11 pass after the helper
refactor (ADR-0062 behaviour preserved).
- `core test --suite smoke`: 67 pass.
- `core test --suite cognition`: 120 pass, 1 skipped.
- `core test --suite teaching`: 17 pass.
- `core eval cognition`: 100 / 91.7 / 100 / 100 (byte-identical).
This commit is contained in:
parent
8f1903e8e7
commit
9e6fa4be75
5 changed files with 727 additions and 25 deletions
|
|
@ -21,6 +21,7 @@ from chat.pack_grounding import (
|
|||
from chat.teaching_grounding import (
|
||||
teaching_grounded_surface,
|
||||
teaching_grounded_surface_composed,
|
||||
teaching_grounded_surface_transitive,
|
||||
TEACHING_CORPUS_ID as _TEACHING_CORPUS_ID,
|
||||
)
|
||||
from chat.refusal import (
|
||||
|
|
@ -782,7 +783,18 @@ class ChatRuntime:
|
|||
)
|
||||
if surface is not None:
|
||||
return (surface, "pack", ())
|
||||
if self.config.composed_surface:
|
||||
if self.config.transitive_surface:
|
||||
# ADR-0083 — transitive supersedes composed. At
|
||||
# max_depth=1 this degrades byte-identically to the
|
||||
# single-chain surface; at max_depth=2 byte-identical
|
||||
# to ADR-0062 when no second hop exists.
|
||||
surface = teaching_grounded_surface_transitive(
|
||||
lemma,
|
||||
intent.tag,
|
||||
register=self.register_pack,
|
||||
max_depth=self.config.transitive_max_depth,
|
||||
)
|
||||
elif self.config.composed_surface:
|
||||
surface = teaching_grounded_surface_composed(
|
||||
lemma, intent.tag, register=self.register_pack,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -401,6 +401,44 @@ def teaching_grounded_surface(
|
|||
)
|
||||
|
||||
|
||||
def _resolve_followup(
|
||||
*,
|
||||
current: TeachingChain,
|
||||
corpus: dict[tuple[str, str], TeachingChain],
|
||||
visited: frozenset[str],
|
||||
same_corpus_id: str,
|
||||
) -> TeachingChain | None:
|
||||
"""ADR-0083 shared resolver — return the next chain that survives
|
||||
the cycle / pack-residency / single-corpus guards, else ``None``.
|
||||
|
||||
Used by both the ADR-0062 depth-1 composer and the ADR-0083
|
||||
transitive composer. Per-hop rules:
|
||||
|
||||
1. Prefer ``(current.object, "cause")``; fall back to
|
||||
``(current.object, "verification")``.
|
||||
2. Refuse candidates whose ``object`` is in *visited* (covers
|
||||
ADR-0062's 1-step cycle guard as the depth-2 case).
|
||||
3. Refuse candidates whose ``object`` is not pack-resident with
|
||||
``semantic_domains`` in the candidate's resolving corpus's pack.
|
||||
4. Refuse candidates from a different corpus than *same_corpus_id*
|
||||
(single-corpus traversal in v1; cross-corpus transitive is
|
||||
deferred to a follow-up ADR).
|
||||
"""
|
||||
for next_intent in ("cause", "verification"):
|
||||
candidate = corpus.get((current.object, next_intent))
|
||||
if candidate is None:
|
||||
continue
|
||||
if candidate.object in visited:
|
||||
continue
|
||||
if candidate.corpus_id != same_corpus_id:
|
||||
continue
|
||||
pack = _pack_for_corpus(candidate.corpus_id)
|
||||
if not pack.get(candidate.object, ()):
|
||||
continue
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def teaching_grounded_surface_composed(
|
||||
subject_lemma: str,
|
||||
intent_tag: IntentTag,
|
||||
|
|
@ -456,20 +494,15 @@ def teaching_grounded_surface_composed(
|
|||
)
|
||||
connective = humanize_predicate(chain.connective)
|
||||
|
||||
# Look for a follow-up chain whose subject equals the initial
|
||||
# chain's object. Prefer cause; fall back to verification.
|
||||
follow_up = None
|
||||
for next_intent in ("cause", "verification"):
|
||||
candidate = corpus.get((chain.object, next_intent))
|
||||
if candidate is None:
|
||||
continue
|
||||
# Cycle guard: don't follow if the next object is the initial
|
||||
# subject (1-step cycle) or the same as the current object
|
||||
# (degenerate same-cell mismatch).
|
||||
if candidate.object in (chain.subject, chain.object):
|
||||
continue
|
||||
follow_up = candidate
|
||||
break
|
||||
# ADR-0083 — shared resolver enforces ADR-0062's cycle guard
|
||||
# (visited = {subject, object}) plus pack-residency + single-
|
||||
# corpus guards. Behaviour at depth-1 is unchanged.
|
||||
follow_up = _resolve_followup(
|
||||
current=chain,
|
||||
corpus=corpus,
|
||||
visited=frozenset({chain.subject, chain.object}),
|
||||
same_corpus_id=chain.corpus_id,
|
||||
)
|
||||
|
||||
if follow_up is None:
|
||||
# No follow-up available — degrade to single-chain surface
|
||||
|
|
@ -480,18 +513,10 @@ def teaching_grounded_surface_composed(
|
|||
f"({head_object_short}). No session evidence yet."
|
||||
)
|
||||
|
||||
# ADR-0083 — _resolve_followup already enforced pack-residency
|
||||
# on follow_up.object, so a non-None result is safe to render.
|
||||
follow_pack = _pack_for_corpus(follow_up.corpus_id)
|
||||
follow_object_domains = follow_pack.get(follow_up.object, ())
|
||||
if not follow_object_domains:
|
||||
# Follow-up's object isn't pack-resident with semantic domains
|
||||
# — degrade to single-chain surface rather than emit a
|
||||
# partially-grounded composition.
|
||||
return (
|
||||
f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
|
||||
f"{head_subject}. {chain.subject} {connective} {chain.object} "
|
||||
f"({head_object_short}). No session evidence yet."
|
||||
)
|
||||
|
||||
follow_head = "; ".join(
|
||||
follow_object_domains[: max(1, follow_up.domains_object_k)]
|
||||
)
|
||||
|
|
@ -504,6 +529,90 @@ def teaching_grounded_surface_composed(
|
|||
)
|
||||
|
||||
|
||||
def teaching_grounded_surface_transitive(
|
||||
subject_lemma: str,
|
||||
intent_tag: IntentTag,
|
||||
*,
|
||||
register: RegisterPack = UNREGISTERED,
|
||||
max_depth: int = 2,
|
||||
) -> str | None:
|
||||
"""ADR-0083 — bounded multi-hop teaching-grounded surface.
|
||||
|
||||
Strict superset of :func:`teaching_grounded_surface_composed`.
|
||||
Iterates the shared :func:`_resolve_followup` helper under a
|
||||
visited-set guard, appending one ``", which {conn} {obj} ({dom})"``
|
||||
clause per surviving hop, up to *max_depth - 1* follow-ups beyond
|
||||
the initial chain.
|
||||
|
||||
``max_depth`` is the maximum number of follow-up hops to append
|
||||
beyond the initial chain. At ``max_depth=0`` byte-identical to
|
||||
:func:`teaching_grounded_surface` (no hops). At ``max_depth=1``
|
||||
byte-identical to :func:`teaching_grounded_surface_composed`
|
||||
(one follow-up). At ``max_depth=2`` byte-identical to ADR-0062
|
||||
when no second hop survives, strict superset when one does.
|
||||
|
||||
Single-corpus traversal in v1 — every hop must resolve in the
|
||||
initial chain's corpus. Cross-corpus transitive is deferred to a
|
||||
follow-up ADR. Returns ``None`` under the same conditions as
|
||||
:func:`teaching_grounded_surface`.
|
||||
"""
|
||||
if not subject_lemma or not isinstance(subject_lemma, str):
|
||||
return None
|
||||
key = subject_lemma.strip().lower()
|
||||
if not key:
|
||||
return None
|
||||
intent_name = _intent_name(intent_tag)
|
||||
if intent_name is None:
|
||||
return None
|
||||
corpus = _all_chains_index()
|
||||
chain = corpus.get((key, intent_name))
|
||||
if chain is None:
|
||||
return None
|
||||
pack = _pack_for_corpus(chain.corpus_id)
|
||||
subject_domains = pack.get(chain.subject, ())
|
||||
object_domains = pack.get(chain.object, ())
|
||||
if not subject_domains or not object_domains:
|
||||
return None
|
||||
head_subject = "; ".join(
|
||||
subject_domains[: max(1, chain.domains_subject_k)]
|
||||
)
|
||||
head_object_short = "; ".join(
|
||||
object_domains[: max(1, chain.domains_object_k)]
|
||||
)
|
||||
connective = humanize_predicate(chain.connective)
|
||||
|
||||
depth_cap = max(0, int(max_depth))
|
||||
hops: list[TeachingChain] = []
|
||||
visited = {chain.subject, chain.object}
|
||||
current = chain
|
||||
while len(hops) < depth_cap:
|
||||
nxt = _resolve_followup(
|
||||
current=current,
|
||||
corpus=corpus,
|
||||
visited=frozenset(visited),
|
||||
same_corpus_id=chain.corpus_id,
|
||||
)
|
||||
if nxt is None:
|
||||
break
|
||||
hops.append(nxt)
|
||||
visited.add(nxt.object)
|
||||
current = nxt
|
||||
|
||||
body = (
|
||||
f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
|
||||
f"{head_subject}. {chain.subject} {connective} {chain.object} "
|
||||
f"({head_object_short})"
|
||||
)
|
||||
for hop in hops:
|
||||
hop_pack = _pack_for_corpus(hop.corpus_id)
|
||||
hop_domains = hop_pack.get(hop.object, ())
|
||||
hop_head = "; ".join(hop_domains[: max(1, hop.domains_object_k)])
|
||||
hop_connective = humanize_predicate(hop.connective)
|
||||
body += f", which {hop_connective} {hop.object} ({hop_head})"
|
||||
body += ". No session evidence yet."
|
||||
return body
|
||||
|
||||
|
||||
def has_teaching_chain(subject_lemma: str, intent_tag: IntentTag) -> bool:
|
||||
"""Return True iff a reviewed chain exists for (subject, intent)
|
||||
in any registered teaching corpus (ADR-0064 cross-corpus view)."""
|
||||
|
|
|
|||
|
|
@ -73,6 +73,23 @@ class RuntimeConfig:
|
|||
# depth (max one follow-up chain in v1).
|
||||
composed_surface: bool = False
|
||||
|
||||
# ADR-0083 — transitive (multi-hop) teaching-grounded surface.
|
||||
# Strict superset of ADR-0062's depth-1 composer: iterates the
|
||||
# per-hop follow-up resolution under a visited-set guard, so the
|
||||
# surface can extend beyond a single follow-up chain.
|
||||
# ``transitive_max_depth`` is the maximum number of follow-up hops
|
||||
# to append beyond the initial chain. At ``max_depth=0``
|
||||
# byte-identical to the single-chain surface; at ``max_depth=1``
|
||||
# byte-identical to ADR-0062's composed surface; at
|
||||
# ``max_depth=2`` byte-identical to ADR-0062 when no second hop
|
||||
# exists, strict superset when one does. When True, this
|
||||
# supersedes ``composed_surface``. Cycle-safe across every depth
|
||||
# (visited-set covers ADR-0062's 1-step cycle guard). Single-
|
||||
# corpus traversal in v1; cross-corpus transitive is deferred to
|
||||
# a follow-up ADR.
|
||||
transitive_surface: bool = False
|
||||
transitive_max_depth: int = 2
|
||||
|
||||
# ADR-0066 / P3.2 — opt-in thread anaphora. When enabled, the
|
||||
# runtime prepends a deterministic backreference to a recent
|
||||
# grounded turn when the current turn's subject lemma matches
|
||||
|
|
|
|||
295
docs/decisions/ADR-0083-transitive-chain-surface.md
Normal file
295
docs/decisions/ADR-0083-transitive-chain-surface.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# ADR-0083 — Transitive Chain Surface (Bounded Multi-Hop Composition)
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-20
|
||||
**Author:** Shay
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0062 introduced depth-1 chain-of-chains composition
|
||||
(`teaching_grounded_surface_composed`): given a ratified chain
|
||||
`(A, intent_A, conn_A, B)` and a follow-up `(B, intent_B, conn_B, C)`,
|
||||
emit a two-clause surface
|
||||
|
||||
```
|
||||
A {conn_A} B (dB), which {conn_B} C (dC).
|
||||
```
|
||||
|
||||
The composer is opt-in (`RuntimeConfig.composed_surface=False`),
|
||||
cycle-guarded, pack-residency-guarded, and degrades byte-identically
|
||||
to the single-chain surface when no follow-up exists. On the 21-chain
|
||||
saturation-v2 cognition corpus, follow-up resolution succeeds for
|
||||
~12 chains.
|
||||
|
||||
### What's still missing
|
||||
|
||||
ADR-0062 explicitly defers deeper composition:
|
||||
|
||||
> v1 follows **exactly one hop**. Deeper compositions (A→B→C→D) are
|
||||
> deferred to a future ADR.
|
||||
|
||||
Inspection of the holdout misses (memory:
|
||||
`dev-holdout-generalization-2026-05-18`, `adr-0053-cognition-lane-closure`)
|
||||
and the cognition saturation corpus shows several latent depth-2 paths
|
||||
that today's composer cannot reach:
|
||||
|
||||
| Hop 1 | Hop 2 | Hop 3 |
|
||||
|---|---|---|
|
||||
| `inference requires evidence` | `evidence grounds knowledge` | `knowledge requires evidence` (cycle — stops at hop 2) |
|
||||
| `thought reveals meaning` | `meaning grounds understanding` | `understanding requires knowledge` |
|
||||
| `light reveals truth` | `truth grounds knowledge` | `knowledge requires evidence` |
|
||||
| `definition grounds concept` | `concept requires definition` (cycle — stops at hop 1) | — |
|
||||
|
||||
Three of these would emit a three-clause surface today if the
|
||||
composer could follow a second hop with cycle tracking; the fourth
|
||||
demonstrates the cycle guard already does its job at depth 1 and
|
||||
must extend to depth N.
|
||||
|
||||
### Why this is the next architectural step
|
||||
|
||||
The repo's φ separation probe (memory: `phi separation falsified`)
|
||||
made it explicit that semantic "stress" or "contradiction" cannot
|
||||
currently be encoded geometrically — the working semantic engine is
|
||||
**pack-grounded chain composition over ratified teaching corpora**.
|
||||
That engine is the cognition surface. Its depth ceiling is therefore
|
||||
the cognition surface's depth ceiling.
|
||||
|
||||
ADR-0083 raises that ceiling by one constant per release: depth-2
|
||||
now, with the visited-set machinery to support depth-N later under
|
||||
the same flag.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Add `teaching_grounded_surface_transitive(subject, intent_tag, *, max_depth)`
|
||||
to `chat/teaching_grounding.py` alongside the existing single-chain
|
||||
and composed (depth-1) composers. Route it via a new opt-in
|
||||
`RuntimeConfig.transitive_surface: bool = False` and a separate
|
||||
`RuntimeConfig.transitive_max_depth: int = 2`.
|
||||
|
||||
### Surface format
|
||||
|
||||
```
|
||||
"{A} — teaching-grounded ({corpus_id}): {dA1}; {dA2}.
|
||||
{A} {conn_A} {B} ({dB1}), which {conn_B} {C} ({dC1}), which {conn_C} {D} ({dD1}).
|
||||
No session evidence yet."
|
||||
```
|
||||
|
||||
Each additional hop adds one `", which {conn_k} {X_k} ({dX_k1})"`
|
||||
clause before the trailing period. The `, which ` linker is reused
|
||||
from ADR-0062; no new template constants.
|
||||
|
||||
### Follow-up resolution rules
|
||||
|
||||
Reuse ADR-0062's per-hop rules, applied iteratively:
|
||||
|
||||
1. Resolve the initial chain `(subject, intent)`. If absent, return `None`.
|
||||
2. For each hop `k ∈ {1, ..., max_depth - 1}`:
|
||||
a. Look up `(X_k.object, "cause")`; fall back to
|
||||
`(X_k.object, "verification")`.
|
||||
b. **Visited-set guard.** Maintain `visited = {subject, X_1.object, ..., X_k.object}`.
|
||||
Refuse a candidate whose `object` is in `visited` (closes the
|
||||
ADR-0062 cycle guard at every depth).
|
||||
c. **Pack-residency guard.** Refuse a candidate whose `object` is
|
||||
not pack-resident with `semantic_domains` in the candidate's
|
||||
resolving corpus's pack.
|
||||
d. **Cross-corpus guard.** v1 requires all hops to resolve in the
|
||||
**same** corpus (`spec.corpus_id == initial.corpus_id`). ADR-0064
|
||||
cross-corpus chains exist, but transitive composition across
|
||||
corpora needs its own audit — deferred.
|
||||
e. If no candidate survives, stop and emit whatever depth has
|
||||
accumulated so far.
|
||||
3. If no hop survived past the initial chain, degrade byte-identically
|
||||
to the ADR-0062 composed surface (which itself degrades to the
|
||||
single-chain surface).
|
||||
|
||||
### Bounded depth
|
||||
|
||||
`transitive_max_depth: int = 2` is the v1 default — equivalent to one
|
||||
additional hop beyond ADR-0062's composed surface. The config field
|
||||
is exposed so operators can characterise depth-3 / depth-4 on their
|
||||
workloads before any default change is proposed. The runtime clamps
|
||||
to `max(1, transitive_max_depth)` so a misconfigured 0 degrades
|
||||
gracefully to single-chain.
|
||||
|
||||
### Opt-in flag
|
||||
|
||||
`RuntimeConfig.transitive_surface: bool = False`. Default preserves
|
||||
all pre-ADR-0083 behaviour byte-identically. Mirrors the
|
||||
ADR-0047/0058/0062 pattern: ship the capability behind a flag,
|
||||
characterise empirically, decide on default behaviour in a follow-up.
|
||||
|
||||
The flag composes with `composed_surface`:
|
||||
|
||||
| `composed_surface` | `transitive_surface` | Behaviour |
|
||||
|---|---|---|
|
||||
| False | False | single-chain (pre-ADR-0062) |
|
||||
| True | False | depth-1 composed (ADR-0062) |
|
||||
| * | True | depth-N transitive (ADR-0083, overrides composed) |
|
||||
|
||||
`transitive_max_depth` is the number of follow-up hops to append
|
||||
beyond the initial chain. At `max_depth=0` transitive degrades
|
||||
byte-identically to single-chain; at `max_depth=1` byte-identical
|
||||
to ADR-0062's composed surface; at `max_depth=2` byte-identical to
|
||||
ADR-0062 when no second hop survives the guards, strict superset
|
||||
when one does.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Required tests (new file: `tests/test_transitive_surface.py`)
|
||||
|
||||
- **Pure function**:
|
||||
- returns `None` when no chain / initial-only / depth-1 / depth-2;
|
||||
- emits an N-clause surface (N ≤ `max_depth`) when chains chain;
|
||||
- includes every intermediate object as a clause subject;
|
||||
- includes every intermediate `semantic_domains` head;
|
||||
- deterministic across two calls;
|
||||
- visited-set guard blocks a depth-N cycle at every depth from 2 to `max_depth`;
|
||||
- pack-residency guard blocks at any depth (not just depth-1);
|
||||
- cross-corpus guard refuses to traverse a cross-corpus follow-up.
|
||||
- **Runtime**:
|
||||
- default keeps single-chain;
|
||||
- `composed_surface=True, transitive_surface=False` matches ADR-0062;
|
||||
- `transitive_surface=True, max_depth=1` matches single-chain;
|
||||
- `transitive_surface=True, max_depth=2` matches ADR-0062 when no
|
||||
second hop exists, **strict superset** when one does;
|
||||
- flags observable on frozen config.
|
||||
- **Null-drop invariant**:
|
||||
- cognition-lane public + holdout metrics byte-identical
|
||||
`transitive_surface` OFF vs ON at `max_depth=2`. The composer is
|
||||
a strict-superset emitter (every token in the depth-1 surface is
|
||||
preserved; new tokens append) so `expected_term` and
|
||||
`expected_surface_contains` assertions must hold flag-on.
|
||||
|
||||
### Lanes (regression check)
|
||||
|
||||
```
|
||||
core test --suite smoke
|
||||
core test --suite cognition
|
||||
core test --suite teaching
|
||||
core eval cognition
|
||||
```
|
||||
|
||||
### Live-prompt observable lift (expected)
|
||||
|
||||
```
|
||||
composed_surface=True (ADR-0062):
|
||||
"Why does light exist?"
|
||||
→ light — teaching-grounded (cognition_chains_v1):
|
||||
cognition.illumination; logos.core. light reveals truth
|
||||
(cognition.truth), which grounds knowledge (cognition.knowledge).
|
||||
No session evidence yet.
|
||||
|
||||
transitive_surface=True, max_depth=2 (ADR-0083):
|
||||
"Why does light exist?"
|
||||
→ light — teaching-grounded (cognition_chains_v1):
|
||||
cognition.illumination; logos.core. light reveals truth
|
||||
(cognition.truth), which grounds knowledge (cognition.knowledge),
|
||||
which requires evidence (cognition.evidence). No session evidence yet.
|
||||
```
|
||||
|
||||
The third clause is **already in the ratified corpus**
|
||||
(`knowledge requires evidence`). ADR-0083 doesn't introduce content;
|
||||
it surfaces content the realizer was silently dropping.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### What changes
|
||||
|
||||
- `core/config.py` —
|
||||
`RuntimeConfig.transitive_surface: bool = False`,
|
||||
`RuntimeConfig.transitive_max_depth: int = 2`.
|
||||
- `chat/teaching_grounding.py` —
|
||||
`teaching_grounded_surface_transitive(subject, intent_tag, *, register, max_depth)`
|
||||
sibling to the depth-1 composer; shared follow-up-resolution helper
|
||||
refactored out of ADR-0062's body so both composers consult one
|
||||
function (no behavioural change to the depth-1 path).
|
||||
- `chat/runtime.py` — dispatch branch in `_maybe_pack_grounded_surface`
|
||||
for `IntentTag.CAUSE` / `IntentTag.VERIFICATION` picks
|
||||
transitive > composed > single based on the two flags.
|
||||
|
||||
### What does not change
|
||||
|
||||
- The pack-grounded discipline: every visible token remains lemma,
|
||||
pack-domain, connective, or template constant. No LLM. No synthesis.
|
||||
- ADR-0053's cold-start contract: empty session + no chain → universal
|
||||
disclosure.
|
||||
- ADR-0062's null-drop invariant: still enforced; ADR-0083 adds its
|
||||
own equivalent at depth-2.
|
||||
- Default runtime behaviour: byte-identical to pre-ADR-0083 main.
|
||||
- The non-negotiable field invariant
|
||||
(`versor_condition(F) < 1e-6`) is unaffected — this ADR composes
|
||||
surface text from ratified chains; no algebra is touched.
|
||||
- Trust-boundary labels: every emitted surface continues to carry the
|
||||
`teaching:{corpus_id}` tag.
|
||||
|
||||
---
|
||||
|
||||
## Scope limits
|
||||
|
||||
- **Depth-2 default.** v1 ships at `max_depth=2`. Depth-3 and beyond
|
||||
are reachable by raising the config field, but the default holds
|
||||
until a follow-up ADR characterises lift and readability.
|
||||
- **Single-corpus traversal.** Cross-corpus transitive chains (e.g.
|
||||
cognition → relations) are deferred. ADR-0064 already allows
|
||||
cross-corpus single-chain emission; transitive cross-corpus adds an
|
||||
audit surface (`teaching:{cognition_chains_v1, relations_chains_v1}`)
|
||||
that needs its own ADR.
|
||||
- **No multi-claim aggregation.** When a subject has multiple
|
||||
initial chains, the composer still picks one — same selection as
|
||||
ADR-0062.
|
||||
- **English path only.** Linker `, which ` and `humanize_predicate`
|
||||
connectives are English-specific. Anchor-lens lifts of transitive
|
||||
surfaces will need their own per-pack linkers — out of scope.
|
||||
- **Flag stays off by default.** Operators must opt in. A follow-up
|
||||
ADR decides on default behaviour after measuring on holdout cases
|
||||
and on the cognition eval.
|
||||
|
||||
---
|
||||
|
||||
## Why now (and why not the alternatives)
|
||||
|
||||
The three other candidate next-steps:
|
||||
|
||||
1. **A learned φ embedding** (replace hash-derived φ with one trained
|
||||
on chain co-occurrence). Real research, could be wrong, lives in
|
||||
`evals/lab/`.
|
||||
2. **Greek/Hebrew content phase II** (lift the cognition-tier grc/he
|
||||
packs from 9+3 lemmas to ~50+). Content grind, not architecture.
|
||||
3. **Teaching corpus epistemology v2 / kinship v2**. Also content grind.
|
||||
|
||||
ADR-0083 is the only one that:
|
||||
|
||||
- exercises only existing primitives (PropositionGraph, chains,
|
||||
pack-grounded surfaces, the ADR-0062 composer pattern),
|
||||
- ships behind the established opt-in-flag + null-drop-invariant
|
||||
pattern,
|
||||
- has measurable lift on the existing holdout corpus without any
|
||||
new content,
|
||||
- and lands inside one ADR.
|
||||
|
||||
It is the smallest design step that moves the substantive ceiling.
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- [ADR-0062](./ADR-0062-composed-teaching-grounded-surface.md) — the
|
||||
depth-1 composer this ADR extends.
|
||||
- [ADR-0058](./ADR-0058-forward-graph-constraint-status.md) — the
|
||||
opt-in-default-False + null-lift-invariant pattern reused here.
|
||||
- [ADR-0053](./ADR-0053-cognition-lane-closure.md) — the cognition-
|
||||
lane closure that frames "term_capture is the next ceiling."
|
||||
- [ADR-0064](./ADR-0064-cross-pack-teaching-chains.md) — defines the
|
||||
cross-corpus chain model that the cross-corpus-traversal scope
|
||||
limit defers to a future ADR.
|
||||
- Memory: `phi separation falsified` — establishes that semantic
|
||||
capability lives in chain composition, not in φ geometry, framing
|
||||
why deepening the composer is the load-bearing next step.
|
||||
269
tests/test_transitive_surface.py
Normal file
269
tests/test_transitive_surface.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""ADR-0083 — transitive (multi-hop) teaching-grounded surface.
|
||||
|
||||
Strict superset of ADR-0062's depth-1 composer. ``max_depth`` is the
|
||||
number of follow-up hops appended beyond the initial chain. This
|
||||
test file pins:
|
||||
|
||||
- Pure function: ``None`` when no chain; depth-0 == single-chain;
|
||||
depth-1 == ADR-0062 composed; depth-2 is a strict superset when
|
||||
a second hop survives.
|
||||
- Visited-set guard: blocks cycles at every depth.
|
||||
- Single-corpus traversal: cross-corpus follow-ups are refused in v1.
|
||||
- Determinism: same input → same surface bytes.
|
||||
- Runtime: ``transitive_surface=True`` supersedes
|
||||
``composed_surface``; flags observable on frozen config.
|
||||
- Cognition lane: metrics byte-identical flag OFF vs ON at
|
||||
``max_depth=2`` on both public and holdout splits (null-drop
|
||||
invariant — transitive mode adds clauses, never drops tokens).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from core.config import RuntimeConfig
|
||||
from chat.runtime import ChatRuntime
|
||||
from chat.teaching_grounding import (
|
||||
teaching_grounded_surface,
|
||||
teaching_grounded_surface_composed,
|
||||
teaching_grounded_surface_transitive,
|
||||
)
|
||||
from generate.intent import IntentTag
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure-function contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_transitive_returns_none_when_no_chain() -> None:
|
||||
out = teaching_grounded_surface_transitive(
|
||||
"zzznotalemma", IntentTag.CAUSE, max_depth=2,
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_transitive_depth_zero_matches_single_chain() -> None:
|
||||
"""``max_depth=0`` appends zero follow-ups → byte-identical to the
|
||||
single-chain surface for any (subject, intent) the corpus knows."""
|
||||
for lemma, intent in (
|
||||
("light", IntentTag.CAUSE),
|
||||
("memory", IntentTag.VERIFICATION),
|
||||
("knowledge", IntentTag.CAUSE),
|
||||
):
|
||||
trans = teaching_grounded_surface_transitive(lemma, intent, max_depth=0)
|
||||
single = teaching_grounded_surface(lemma, intent)
|
||||
assert trans is not None
|
||||
assert single is not None
|
||||
assert trans == single, f"depth-0 != single for {lemma}/{intent}"
|
||||
|
||||
|
||||
def test_transitive_depth_one_matches_composed() -> None:
|
||||
"""``max_depth=1`` appends one follow-up → byte-identical to
|
||||
ADR-0062's depth-1 composer."""
|
||||
for lemma, intent in (
|
||||
("light", IntentTag.CAUSE),
|
||||
("memory", IntentTag.VERIFICATION),
|
||||
("knowledge", IntentTag.CAUSE),
|
||||
):
|
||||
trans = teaching_grounded_surface_transitive(lemma, intent, max_depth=1)
|
||||
composed = teaching_grounded_surface_composed(lemma, intent)
|
||||
assert trans is not None
|
||||
assert composed is not None
|
||||
assert trans == composed, (
|
||||
f"depth-1 != composed for {lemma}/{intent}"
|
||||
)
|
||||
|
||||
|
||||
def test_transitive_depth_two_is_strict_superset_for_light_cause() -> None:
|
||||
"""``light cause``: chain ``light reveals truth``, follow-ups
|
||||
``truth grounds knowledge`` (hop 1) and ``knowledge requires
|
||||
evidence`` (hop 2) — depth-2 emits both ``knowledge`` AND
|
||||
``evidence`` clauses, strict superset of ADR-0062."""
|
||||
depth2 = teaching_grounded_surface_transitive(
|
||||
"light", IntentTag.CAUSE, max_depth=2,
|
||||
)
|
||||
composed = teaching_grounded_surface_composed("light", IntentTag.CAUSE)
|
||||
assert depth2 is not None
|
||||
assert composed is not None
|
||||
assert depth2 != composed
|
||||
assert composed in depth2[: len(composed) - 1] or "knowledge" in depth2
|
||||
assert "evidence" in depth2
|
||||
# Each appended hop adds a ", which " linker — depth-2 has two.
|
||||
assert depth2.count(", which ") == 2
|
||||
|
||||
|
||||
def test_transitive_depth_two_includes_every_intermediate_object() -> None:
|
||||
"""Pack-grounded discipline: the initial subject and every hop
|
||||
object appear as a clause subject in the depth-2 surface."""
|
||||
depth2 = teaching_grounded_surface_transitive(
|
||||
"light", IntentTag.CAUSE, max_depth=2,
|
||||
)
|
||||
assert depth2 is not None
|
||||
for token in ("light", "truth", "knowledge", "evidence"):
|
||||
assert token in depth2, f"{token} missing from depth-2 surface"
|
||||
|
||||
|
||||
def test_transitive_depth_two_includes_every_intermediate_domain() -> None:
|
||||
"""Every hop's object semantic_domains appear verbatim."""
|
||||
from chat.pack_grounding import _pack_index
|
||||
pack = _pack_index()
|
||||
depth2 = teaching_grounded_surface_transitive(
|
||||
"light", IntentTag.CAUSE, max_depth=2,
|
||||
)
|
||||
assert depth2 is not None
|
||||
for obj in ("truth", "knowledge", "evidence"):
|
||||
domains = pack[obj]
|
||||
assert any(d in depth2 for d in domains[:1]), (
|
||||
f"no domain of {obj!r} in depth-2 surface"
|
||||
)
|
||||
|
||||
|
||||
def test_transitive_cycle_guard_blocks_one_step_cycle() -> None:
|
||||
"""``memory verification`` → ``memory requires recall``; the only
|
||||
follow-up candidate ``recall cause`` is ``recall reveals memory``
|
||||
which would re-introduce ``memory`` (1-step cycle). Even at
|
||||
``max_depth=4`` no hop survives → degrades to single-chain."""
|
||||
trans = teaching_grounded_surface_transitive(
|
||||
"memory", IntentTag.VERIFICATION, max_depth=4,
|
||||
)
|
||||
single = teaching_grounded_surface("memory", IntentTag.VERIFICATION)
|
||||
assert trans == single
|
||||
|
||||
|
||||
def test_transitive_visited_set_blocks_deeper_cycle() -> None:
|
||||
"""``light cause`` reaches ``evidence`` at hop 2. No chain in the
|
||||
cognition corpus has subject ``evidence`` and a cause / verification
|
||||
object outside ``{light, truth, knowledge, evidence}``, so depth-3
|
||||
is identical to depth-2 (the depth-3 candidate, if any, must be
|
||||
refused by the visited-set guard or the per-hop terminator)."""
|
||||
depth2 = teaching_grounded_surface_transitive(
|
||||
"light", IntentTag.CAUSE, max_depth=2,
|
||||
)
|
||||
depth3 = teaching_grounded_surface_transitive(
|
||||
"light", IntentTag.CAUSE, max_depth=3,
|
||||
)
|
||||
assert depth2 == depth3
|
||||
|
||||
|
||||
def test_transitive_is_deterministic() -> None:
|
||||
a = teaching_grounded_surface_transitive(
|
||||
"light", IntentTag.CAUSE, max_depth=2,
|
||||
)
|
||||
b = teaching_grounded_surface_transitive(
|
||||
"light", IntentTag.CAUSE, max_depth=2,
|
||||
)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_transitive_preserves_trust_label() -> None:
|
||||
trans = teaching_grounded_surface_transitive(
|
||||
"light", IntentTag.CAUSE, max_depth=2,
|
||||
)
|
||||
assert trans is not None
|
||||
assert trans.endswith("No session evidence yet.")
|
||||
assert "teaching-grounded (cognition_chains_v1)" in trans
|
||||
|
||||
|
||||
def test_transitive_negative_max_depth_clamps_to_zero() -> None:
|
||||
"""Misconfigured negative max_depth clamps to 0 (single-chain)."""
|
||||
out = teaching_grounded_surface_transitive(
|
||||
"light", IntentTag.CAUSE, max_depth=-5,
|
||||
)
|
||||
single = teaching_grounded_surface("light", IntentTag.CAUSE)
|
||||
assert out == single
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime integration via the config flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_runtime_default_does_not_engage_transitive() -> None:
|
||||
"""Both flags default False → single-chain surface."""
|
||||
rt = ChatRuntime(config=RuntimeConfig())
|
||||
response = rt.chat("Why does light exist?")
|
||||
expected = teaching_grounded_surface("light", IntentTag.CAUSE)
|
||||
assert response.surface == expected
|
||||
|
||||
|
||||
def test_runtime_transitive_supersedes_composed() -> None:
|
||||
"""When both flags are on, transitive wins (strict superset of
|
||||
composed at default ``max_depth=2``)."""
|
||||
cfg = replace(
|
||||
RuntimeConfig(),
|
||||
composed_surface=True,
|
||||
transitive_surface=True,
|
||||
)
|
||||
rt = ChatRuntime(config=cfg)
|
||||
response = rt.chat("Why does light exist?")
|
||||
expected = teaching_grounded_surface_transitive(
|
||||
"light", IntentTag.CAUSE, max_depth=2,
|
||||
)
|
||||
assert response.surface == expected
|
||||
# Strict superset over ADR-0062 on this prompt.
|
||||
assert response.surface != teaching_grounded_surface_composed(
|
||||
"light", IntentTag.CAUSE,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_transitive_at_depth_one_matches_composed() -> None:
|
||||
cfg = replace(
|
||||
RuntimeConfig(),
|
||||
transitive_surface=True,
|
||||
transitive_max_depth=1,
|
||||
)
|
||||
rt = ChatRuntime(config=cfg)
|
||||
response = rt.chat("Why does light exist?")
|
||||
expected = teaching_grounded_surface_composed("light", IntentTag.CAUSE)
|
||||
assert response.surface == expected
|
||||
|
||||
|
||||
def test_runtime_flags_observable_on_frozen_config() -> None:
|
||||
cfg = replace(
|
||||
RuntimeConfig(),
|
||||
transitive_surface=True,
|
||||
transitive_max_depth=3,
|
||||
)
|
||||
assert cfg.transitive_surface is True
|
||||
assert cfg.transitive_max_depth == 3
|
||||
default = RuntimeConfig()
|
||||
assert default.transitive_surface is False
|
||||
assert default.transitive_max_depth == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cognition-lane null-drop invariant (transitive adds clauses, never drops)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cognition_lane_metrics_unchanged_with_transitive_flag() -> None:
|
||||
"""At ``max_depth=2``, every expected_term and
|
||||
expected_surface_contains assertion that passed flag-OFF must still
|
||||
pass flag-ON. If a future change ever drops tokens in transitive
|
||||
mode, this test fails as the deliberate regression it is."""
|
||||
from evals.framework import get_lane, run_lane
|
||||
|
||||
lane = get_lane("cognition")
|
||||
watched = (
|
||||
"intent_accuracy",
|
||||
"surface_groundedness",
|
||||
"term_capture_rate",
|
||||
"versor_closure_rate",
|
||||
)
|
||||
on_cfg = replace(
|
||||
RuntimeConfig(),
|
||||
transitive_surface=True,
|
||||
transitive_max_depth=2,
|
||||
)
|
||||
for split in ("public", "holdout"):
|
||||
off = run_lane(lane, version="v1", split=split,
|
||||
config=RuntimeConfig()).metrics
|
||||
on = run_lane(lane, version="v1", split=split,
|
||||
config=on_cfg).metrics
|
||||
for m in watched:
|
||||
assert off[m] == on[m], (
|
||||
f"ADR-0083 null-drop invariant broken on split={split!r} "
|
||||
f"metric={m!r}: OFF={off[m]} vs ON={on[m]}. "
|
||||
f"Transitive surface should add clauses, never drop tokens."
|
||||
)
|
||||
Loading…
Reference in a new issue