Commit graph

4 commits

Author SHA1 Message Date
Shay
bf8284fd47 Phase 2 — proposition-slot grounding for articulate_with_intent
Root cause: recalled_words was built from result.tokens (versor walk
neighbours) rather than the pack-resolved proposition slots. The walk
produces nearest-neighbour traversal artifacts; the proposition already
carries the correct subject/predicate/object from realize(). This made
ground_graph() fill <pending> obj slots with stop-word-adjacent tokens
instead of the actual answer content.

Fix — two changes, one new helper:

generate/intent_bridge.py
  • build_recalled_words_from_plan(plan, proposition, walk_tokens)
    Constructs the grounding tuple in priority order:
      1. plan.object  (ArticulationPlan — pack-resolved, already a word)
      2. proposition.object_  (Proposition — versor-decoded object slot)
      3. plan.predicate  (descriptive predicate word, richer than walk)
      4. plan.subject  (subject as last-resort semantic anchor)
      5. walk_tokens  (result.tokens alpha-filtered — supplemental backfill)
    Strips <pending>/<prior>/empty/non-alpha before deduplicating.
    Returns a deduplicated tuple in that priority order.
  • articulate_with_intent() gains an optional `proposition` param
    (typed as object to avoid import coupling at the call site).
    When provided, build_recalled_words_from_plan() is called to
    replace the raw recalled_words before ground_graph() runs.
    When omitted, behaviour is byte-identical to Phase 1 (backward
    compatible: all existing callers and tests pass unchanged).

chat/runtime.py
  • The single articulate_with_intent() call site now passes
    proposition=proposition so the bridge receives the full
    pack-resolved proposition for grounding. walk_tokens (the old
    recalled_words) are passed through as supplemental backfill.
  • No change to ChatResponse, TurnEvent, GenerationResult, or any
    ADR-gated schema.
2026-05-18 18:18:31 -07:00
Shay
b9778b85df Phase 1 — bridge trace instrumentation (observation-only)
Adds generate/bridge_trace.py: a structured sink + serializer for
per-turn articulation-bridge trace records, following the exact
ADR-0040 telemetry sink pattern (JsonlBufferSink / JsonlFileSink /
FanOutSink, no wall-clock, redact-by-default).

Modifies generate/intent_bridge.py: articulate_with_intent() emits
one BridgeTraceRecord per call through a module-level opt-in sink
(attach_bridge_trace_sink / detach_bridge_trace_sink).  When no
sink is attached the call is a pure no-op — zero behavior change on
all existing paths.

The record captures:
  - intent_tag / intent_subject  (classifier output)
  - plan_subject / plan_predicate / plan_object  (articulation slots)
  - recalled_words_len / recalled_words_sample  (grounding supply)
  - pre_ground_obj  (what the graph node held before ground_graph)
  - post_ground_obj  (what it held after, or same if no grounding ran)
  - bridge_surface / bridge_useful  (final output + usefulness gate)
  - fallback_surface  (the plan.surface the runtime falls back to)

This is the Phase 1 measurement instrumentation described in the
full-sentence output mastery plan.  Phases 2-5 act on the data this
produces; Phase 1 itself is pure observation.
2026-05-18 18:04:57 -07:00
Shay
f47a85a3e7 feat(adr-0047): wire forward graph constraint into the chat hot path
Closes ADR-0046's deferred follow-up: convert the PropositionGraph
into an AdmissibilityRegion BEFORE generate() runs on the live
chat path.

== generate/intent_bridge.py ==

New public helper:

    build_graph_from_input(text, plan) -> PropositionGraph

Same internal call as _build_graph_from_intent, without the
post-generation ground_graph step — suitable for forward use.

== chat/runtime.py ==

When the new flag is on and output language is English, build the
graph and the region before generate() and pass it via region=.
Empty / fully OOV graphs return AdmissibilityRegion(allowed_indices=None),
which generate() treats as unconstrained — the change is a true
no-op when the graph carries no in-vocab anchors.

== core/config.py ==

RuntimeConfig.forward_graph_constraint: bool = False

Default False preserves all pre-ADR-0046 behaviour and the ADR-0024
honest-refusal contract.  A first attempt wired the constraint
unconditionally; 15 tests failed with InnerLoopExhaustion because the
intent-derived graph's CGA neighbourhood doesn't intersect the walk's
candidate pool with top_k=8 on the current packs.  The honest answer
is not to widen top_k until the failure goes away nor to silently
relax — both erase the architectural information that the geometry
of the graph and the geometry of the walk are not yet co-located.
Opt-in preserves ADR-0024 and follows the ADR-0022→0026 transition-
window pattern.

== Characterisation (core eval cognition, 13-case public split) ==

A/B with the flag toggled:

  Metric                  OFF      ON      Δ
  intent_accuracy        100.0%   100.0%   0
  surface_groundedness    15.4%    15.4%   0
  term_capture_rate        0.0%     0.0%   0
  versor_closure_rate    100.0%   100.0%   0
  InnerLoopExhaustion       0        0     0
  non-trivial constraint   n/a    6 / 13   —

Findings:
- Wiring is correct and safe (no exhaustions, closure unchanged).
- Single-token in-vocab subjects engage the constraint
  (light/knowledge/meaning/memory/correction).
- Multi-word OOV subject phrases produced by the intent classifier
  fall through to unconstrained — this is the existing intent-
  classifier contract surfacing into geometry, not a constraint bug.
- Restricting which tokens the walk may visit did not change
  surface_groundedness or term_capture_rate on this lane.  The
  surface-grounding gap therefore lives downstream of propagation
  — in the realizer / surface-assembly / dialogue-role path — and is
  the next load-bearing pull.  This isolates the next ADR's scope.

== tests/test_forward_graph_constraint_wiring.py (5 tests) ==

  - DEFAULT_CONFIG.forward_graph_constraint is False
  - Default runtime answers without InnerLoopExhaustion
  - Opt-in runtime answers on a short benign input
  - Graph builder + build_graph_constraint produce a labelled
    AdmissibilityRegion ("graph:unconstrained" or "graph:<root_id>")
  - Flag is observable on the frozen RuntimeConfig

== docs/decisions/ ==

  - ADR-0047 ratifies the wire-up, opt-in rationale, and A/B numbers.
  - README index updated; the Pillar 1→2→3 section now reflects both
    the primitive (ADR-0046) and the live wiring (ADR-0047), and
    names the next pull (realizer / surface assembly) explicitly.

Verification (this branch):

  tests/test_forward_graph_constraint_wiring.py    5 passed
  tests/test_graph_constraint.py                   8 passed
  core test --suite smoke                         67 passed
  core test --suite cognition                    121 passed
  core test --suite runtime                       19 passed
  core test --suite algebra                      132 passed
  core test --suite teaching                      17 passed
  core test --suite packs                          6 passed
  core eval cognition                            metrics unchanged from main

versor_condition(F) < 1e-6 invariant unaffected.
2026-05-18 06:18:10 -07:00
Shay
f223e61352 fix(generate): wire intent-aware realizer into chat hot path
The realize_semantic / realize_target pipeline in realizer.py was fully
implemented but never called from chat/runtime.py. The hot path only called
realize() from articulation.py, which returns raw S-P-O word tokens with no
intent, tense, negation, quantifier or rhetorical-move awareness. This
disconnected the 13-construction realizer from every live chat turn.

New module generate/intent_bridge.py:
- classify_intent_from_input() runs the rule-based classifier against the
  raw input text to obtain a DialogueIntent
- articulate_with_intent() builds a PropositionGraph from that intent,
  grounds the <pending> obj slots with recalled vocabulary from the
  generation result, plans articulation via plan_articulation(), and calls
  realize_semantic() for the intent-specific template path
- Falls back cleanly to the existing ArticulationPlan surface when the
  realizer returns an empty plan (OOV-heavy or UNKNOWN intent)

chat/runtime.py change:
- Import and call articulate_with_intent() after the existing realize() call
- Replace articulation.surface with the intent-bridge surface whenever the
  bridge returns a non-empty, non-pending string
- The existing ArticulationPlan dataclass is preserved and passed downstream
  so SentenceAssembler, turn_log, ChatResponse, and all trace fields remain
  structurally unchanged

Effect: chat() now produces intent-differentiated surfaces:
  DEFINITION  → "X is defined as Y"         (was "X Y Z")
  CAUSE       → "X is grounded in Y"         (was "X Y Z")
  CORRECTION  → "correction: X corrects Y"   (was "X Y Z")
  RECALL      → "recalling X: Y"             (was "X Y Z")
  VERIFICATION→ "X is verified: Y"           (was "X Y Z")
  COMPARISON  → "X and Y are distinguished..." (was "X contrasts_with Y")
  PROCEDURE   → "first, Y; then, X follows"  (was "X Y Z")
  CONJUNCTION → "X P and Y P"               (realizer edge handling)
  RELATIVE    → "X, which Pv Y, Pv Z"       (realizer edge handling)

Articulation fidelity is now geometrically honest AND structurally expressive.
The surface corresponds to internal intent state, not a generic S-P-O join.
2026-05-16 08:38:59 -07:00