Skip to content

feat(v1.4): rebuilder trigger modes — manual + threshold (+ dynamic parked) (closes #141) - #179

Merged
robotrocketscience merged 10 commits into
mainfrom
feat/v1.4-trigger-modes
Apr 28, 2026
Merged

feat(v1.4): rebuilder trigger modes — manual + threshold (+ dynamic parked) (closes #141)#179
robotrocketscience merged 10 commits into
mainfrom
feat/v1.4-trigger-modes

Conversation

@robotrocketscience

Copy link
Copy Markdown
Owner

Summary

Three trigger-mode surfaces for the v1.4 context rebuilder, sequenced per spec (#141):

  • Manual/aelf:rebuild slash command (new file src/aelfrice/slash_commands/rebuild.md) and the now-visible aelf rebuild CLI subcommand both drive the same rebuild_v14() codepath. Manual is the v1.4 ship default.
  • Threshold — auto-fires when called by Claude Code's PreCompact harness; default threshold_fraction = 0.6 is sourced from eval-harness calibration, not hand-picked. New benchmarks/context_rebuilder/calibrate.py sweeps thresholds 0.5/0.6/0.7/0.8/0.9 against the bundled synthetic fixture, scores each post-clear assistant turn by content-overlap on the <retrieved-beliefs> sub-block, picks the fraction with max efficiency (fidelity / token_cost_ratio, rounded to 3dp; lowest threshold breaks ties). Committed JSON at benchmarks/context-rebuilder/calibration_v1_4_0.json is the reproducible source-of-truth.
  • Dynamic — parked v1.5. New benchmarks/context_rebuilder/dynamic_probe.py measured two heuristic candidates (rate-of-context-growth, entity-density-delta) against the same fixture; neither cleared the spec's "≥ 5% absolute fidelity over threshold at same-or-lower token cost" gate (rate-of-growth: cost ratio 1.461 vs threshold's 1.017 — fails cost half; entity-density-delta: fidelity delta +0.0147 absolute vs +0.05 gate). Setting trigger_mode = "dynamic" at v1.4 logs a parked v1.5 trace and no-ops.

Calibration table

threshold_fraction clear_at n post-clear rebuild tokens full-replay tokens ratio fidelity efficiency
0.5 8 4 641 657 0.976 0.051 0.052
0.6 9 3 668 657 1.017 0.068 0.067
0.7 11 2 756 657 1.151 0.069 0.060
0.8 12 2 851 657 1.295 0.069 0.054
0.9 14 1 938 657 1.428 0.095 0.067

Choice rule: filter to ratio <= 1.5; max efficiency (rounded to 3dp); tie-break on lowest threshold. Result: 0.6.

Dynamic-mode verdict — park (with evidence)

Reference: threshold-mode at 0.6 — fidelity 0.0677, ratio 1.017.

  • rate-of-context-growth — never trips on the balanced synthetic fixture; falls back to last turn → fidelity vacuous 1.0 but ratio 1.461 (>1.017). Fails cost half of the gate.
  • entity-density-delta — fires at turn 4 → fidelity 0.0824, ratio 0.682. Fidelity delta vs threshold = +0.0147 absolute (well below +0.05 spec gate).

Reproduce: python -m benchmarks.context_rebuilder.dynamic_probe benchmarks/context-rebuilder/fixtures/synthetic/debugging_session_001.jsonl.

Tests

  • 16 new acceptance tests in tests/test_rebuilder_triggers.py covering manual default, threshold fire, calibration source-of-truth (+ byte-for-byte reproducibility), dynamic-mode parking + parked-trace, and the parser-side trigger_mode validation.
  • Existing tests/test_context_rebuilder_hook.py + tests/test_hook_pre_compact.py get a [rebuilder] trigger_mode = "threshold" opt-in fixture so the auto-fire path tests stay valid against the new manual-default ship.
  • tests/test_slash_commands.py and tests/test_cli_advanced_help.py updated for the rebuild slash promotion.

uv run pytest -q: 1414 passed, 2 skipped. Pyright strict-clean on every touched file.

Test plan

  • uv run pytest -q green
  • uv run pyright strict-clean on touched files
  • Calibration script idempotent: python -m benchmarks.context_rebuilder.calibrate ... --out X produces byte-identical JSON to the committed file
  • Dynamic probe verdict regression-tested: tests/test_rebuilder_triggers.py::test_tm5_dynamic_probe_verdict_is_park
  • All commits SSH-signed
  • CI: 8 staging-gate checks green (needs CI run)

Closes #141.

…ief, bm25)

Sibling of search_beliefs that exposes the raw FTS5 BM25 score per
hit (SQLite returns it as a non-positive float, smaller = better).

Used by v1.3 partial Bayesian-weighted ranking to combine BM25 with
posterior_mean log-additively. The existing search_beliefs surface
is unchanged.
…log-additively

Implements the v1.3.0 score function from docs/bayesian_ranking.md:

    score = log(max(-bm25_raw, EPS))
          + posterior_weight * log(posterior_mean(alpha, beta))

- Reuses scoring.posterior_mean (Jeffreys prior). Spec rejects the
  Laplace (alpha+1)/(alpha+beta+2) sketch from #151.
- DEFAULT_POSTERIOR_WEIGHT = 0.5 (the synthetic-graph optimum).
- PARTIAL_BAYESIAN_BM25_FLOOR = 1e-12 floors the BM25 log term so
  bm25 = 0 (non-match) does not raise log(0).
- posterior_weight = 0.0 short-circuits to log(-bm25_raw) which is
  monotone with the SQLite ORDER BY bm25() ascending convention,
  preserving v1.0.x byte-identical ordering.

No retrieval wiring yet -- next commit.
Implements docs/bayesian_ranking.md acceptance criteria 1, 2, 6.

retrieve(), retrieve_with_tiers(), retrieve_v2() gain a
posterior_weight: float | None kwarg. None triggers precedence
resolution via resolve_posterior_weight():

  1. AELFRICE_POSTERIOR_WEIGHT env var (float).
  2. Explicit kwarg from caller.
  3. [retrieval] posterior_weight in .aelfrice.toml.
  4. DEFAULT_POSTERIOR_WEIGHT = 0.5.

L1 hits flow through new private _l1_hits() helper:
- weight == 0.0: short-circuit to store.search_beliefs() (BM25
  ascending), preserving v1.0.x byte-identical ordering.
- weight > 0: store.search_beliefs_scored(), score with
  scoring.partial_bayesian_score(), sort descending. Tie-break
  on belief id ASC for determinism.

Locks (L0), L2.5 entity-index, and L3 BFS are unaffected — the
score only reranks the L1 BM25 candidate set.

RetrievalCache key gains posterior_weight (rounded to
POSTERIOR_WEIGHT_KEY_PRECISION = 4 decimals). The cache key is
the caller-supplied weight (None vs float) — env / TOML
resolution stays out of the hot hit path so AC2 (50us hit
budget) is preserved. Same-weight queries hit; different-weight
queries miss. Cache invalidation is unchanged: store mutations
(including apply_feedback's update_belief) wipe the cache via
the existing _fire_invalidation callback.

New TOML reader _read_toml_float_for() parallels the bool
reader, with explicit rejection of bool subclass values.
One test per spec acceptance criterion (docs/bayesian_ranking.md
§ 'Acceptance criteria for the implementation PR'):

- AC1  retrieve / retrieve_v2 accept posterior_weight kwarg.
- AC2  posterior_weight=0.0 byte-identical to v1.0.x ordering.
- AC3  equal-BM25 hits ordered by posterior_mean DESC.
- AC4  high-BM25/low-posterior drops below low-BM25/high-posterior.
- AC5  apply_feedback promotes a mid-rank belief.
- AC6  RetrievalCache key gains posterior_weight (hit/miss matrix).
- AC7  apply_feedback wipes cache via store callback (no direct
       cache.invalidate() call).
- AC8  Locked beliefs unaffected at weights {0.0, 0.5, 1.0}.
- AC9  Cold-belief neutrality: all-prior corpus collapses to BM25.
- AC10 bm25 == 0 edge case does not crash (log(0) floor).
- AC11 Per-query overhead within latency budget.
- AC12 docs/LIMITATIONS.md documents the partial ranker.
- AC13 docs/ROADMAP.md links the spec.

Plus pin tests for:
- DEFAULT_POSTERIOR_WEIGHT == 0.5.
- resolve_posterior_weight precedence (env > kwarg > TOML > default).
- Negative weights clamp to 0.0.
- Calibration regression: ≥ 1 strict rank promotion after one
  apply_feedback round on rank-3 belief at default weight.
- partial_bayesian_score uses Jeffreys posterior_mean (not Laplace).
- BM25 floor constant is positive and small.

All deterministic, ≤2s, pass under pyright strict.
- docs/CONFIG.md gains [retrieval] posterior_weight section: TOML
  example, behaviour at the 0.0 / 0.5 / >1.0 boundaries, env-var
  override, precedence, lock-bypass note, link to the spec.
- CHANGELOG.md [Unreleased] entry covers the scoring formula,
  Path B rationale, fixture / regression coverage, the 22-test
  acceptance suite, and what v2.0.0 still owes per the spec.

docs/LIMITATIONS.md already carried the v1.3.0 paragraph; no
change needed there. docs/ROADMAP.md § v1.3.0 already linked
docs/bayesian_ranking.md; AC13 satisfied without edit.
Adds [rebuilder] trigger_mode and threshold_fraction to RebuilderConfig,
defaulting to "manual" / 0.7 at v1.4.0. The PreCompact hook now no-ops
when trigger_mode='manual' so the rebuilder fires only on explicit user
invocation. trigger_mode='dynamic' logs a "parked v1.5" trace and
no-ops; threshold-mode behaviour is unchanged.

Promotes the previously-hidden `aelf rebuild` subparser to a visible
verb and ships /aelf:rebuild as the manual surface (slash file in
src/aelfrice/slash_commands/rebuild.md). Matches the spec's "Manual
ships first as the explicit testing surface" sequencing rule.

Existing hook regression tests (test_context_rebuilder_hook.py) opt
into trigger_mode='threshold' via a new _write_threshold_config()
helper; the slash-command surface test moves `rebuild` from the
HIDDEN_SUBCOMMANDS set into EXPECTED_COMMANDS.
Adds the "threshold" trigger mode and seeds its default
threshold_fraction (0.6) from the eval-harness calibration, not
from a hand-picked number.

New benchmarks/context_rebuilder/calibrate.py sweeps threshold
fractions 0.5/0.6/0.7/0.8/0.9 against the bundled synthetic fixture,
seeds an in-memory MemoryStore with one belief per pre-clear
assistant turn, calls rebuild_v14() at a compressed token_budget
(200) so the retrieved-beliefs section has to choose what to
surface, and scores each post-clear assistant turn by content-
overlap on the <retrieved-beliefs> sub-block. Choice rule:
filter to ratio <= 1.5, max efficiency (fidelity/ratio) rounded
to 3dp, lowest threshold breaks ties. The committed JSON at
benchmarks/context-rebuilder/calibration_v1_4_0.json is the
reproducible source-of-truth; DEFAULT_THRESHOLD_FRACTION pins to
the chosen value.

When trigger_mode='threshold', the PreCompact hook fires as in
v1.2.0a0 (the harness's own PreCompact firing is the gate;
threshold_fraction documents the calibrated operating point).

docs/context_rebuilder.md gains § Threshold calibration with the
sweep table + choice rule + per-fixture caveat. The shipped-vs-
parked acceptance table is updated.

Pyright strict-clean on every touched file. Dynamic-mode
investigation lands in the next commit.
…141)

New benchmarks/context_rebuilder/dynamic_probe.py runs two
heuristic-driven trigger candidates against the bundled synthetic
fixture and emits a JSON verdict against the spec ship-gate
("dynamic mode beats threshold by >= 5% absolute fidelity at
same-or-lower token cost").

Candidate 1 (rate-of-context-growth): fires at first turn whose
4-turn rolling per-turn token average exceeds 1.5x the fixture-
wide median. On the synthetic fixture the rule never trips and
falls back to the last turn -> token ratio 1.461 (vs threshold
1.017) fails the cost half of the gate.

Candidate 2 (entity-density-delta): fires at first turn (after
a 4-turn warmup) whose new-entity count drops below half the
fixture median. Fires at turn 4 with fidelity 0.0824 and ratio
0.682 (cheaper than threshold's 1.017) -- but fidelity delta of
+0.0147 absolute is well below the +0.05 spec gate.

Verdict: park. docs/context_rebuilder.md gains
§ Dynamic mode (parked v1.5) with the full measurement table and
park rationale. The implementation stays in the tree so a v1.5.x
re-investigation can build on the same proxy + fixture without
re-deriving them.

The dynamic-mode hook gate (already in place from the manual-mode
commit) logs a "parked v1.5" trace to stderr and no-ops when
trigger_mode = "dynamic" is set in .aelfrice.toml.
New tests/test_rebuilder_triggers.py adds 16 deterministic
acceptance tests for the trigger-mode behaviour:

- TM1: manual is the v1.4 default; PreCompact hook no-ops in
  manual mode; aelf rebuild bypasses the trigger gate (manual
  surface always works).
- TM2: threshold mode emits the envelope; threshold default
  matches the calibration JSON's chosen value; out-of-range
  threshold_fraction degrades to default; .aelfrice.toml
  override wins.
- TM3: calibration JSON is committed and well-formed; running
  calibrate.py re-derives byte-for-byte (excluding the caller-
  chosen `fixture` path field).
- TM4: dynamic is in the valid-modes set; setting it logs a
  "parked v1.5" trace to stderr from both pre_compact() and
  context_rebuilder.main(), and writes nothing to stdout.
- TM5: dynamic_probe verdict is "park" on the synthetic fixture
  (regression-test on the parking decision; flips CI red if a
  future change would re-enable dynamic mode without docs).
- Edge: parser rejects unknown trigger_mode strings;
  every valid mode round-trips through the parser.

Plus two existing-test fixups required by the manual-mode-default
flip:
- tests/test_hook_pre_compact.py: write `[rebuilder] trigger_mode
  = "threshold"` in `_aelfrice_log` and the falls-back-to-Claude-
  transcript test so the auto-fire-path tests stay valid.
- tests/test_cli_advanced_help.py: `rebuild` is no longer hidden
  from --help (it became the user-facing manual-trigger surface).

uv run pytest -q stays green: 1430 passed, 2 skipped.
Pyright strict-clean on test_rebuilder_triggers.py.
CHANGELOG.md [Unreleased] gains an Added entry covering the
manual + threshold ship + dynamic-parking decision, with pointers
to the calibration JSON, the dynamic_probe ship-gate result, and
the regression tests.

ROADMAP.md § v1.4.0 Trigger modes bullet replaced with the v1.4
shipped-as-of state plus the v1.5 park reference.
@robotrocketscience
robotrocketscience merged commit ef444c9 into main Apr 28, 2026
8 checks passed
@robotrocketscience
robotrocketscience deleted the feat/v1.4-trigger-modes branch April 28, 2026 14:43
yoshi280 pushed a commit that referenced this pull request Apr 28, 2026
…181)

## Summary

Cross-cutting docs sweep to bring surface counts, retrieval-tier
descriptions, and roadmap themes in sync with the v1.3 (PRs #171#178)
and v1.4 (PRs #175#179) work that landed on main.

## Per-item status

| Item | File | Status |
|---|---|---|
| Test count in RELEASING.md | `docs/RELEASING.md` | fixed — ~1,150 →
~1,414 |
| Test count in ARCHITECTURE.md | `docs/ARCHITECTURE.md` | fixed —
~1,150 → ~1,414 |
| CLI subcommand count in COMMANDS.md | `docs/COMMANDS.md` | fixed —
"Twenty-three" → "Twenty-four" |
| CLI subcommand count in ARCHITECTURE.md | `docs/ARCHITECTURE.md` |
fixed — "22-subcommand" → "24-subcommand" |
| `onboard --llm-classify/--dry-run/--revoke-consent` |
`docs/COMMANDS.md` | fixed — added to onboard table entry |
| `aelf --advanced` flag | `docs/COMMANDS.md` | fixed — new "Help flags"
section added |
| ARCHITECTURE retrieval tiers (L2.5, L3 BFS, Bayesian) |
`docs/ARCHITECTURE.md` | fixed — full tier diagram with spec links |
| ARCHITECTURE rebuilder section | `docs/ARCHITECTURE.md` | fixed —
PreCompact flow diagram + context_rebuilder.md link |
| ARCHITECTURE LLM classifier | `docs/ARCHITECTURE.md` | fixed — added
to Onboarding section with llm_classifier.md link |
| ARCHITECTURE "Out of scope" — shipped items | `docs/ARCHITECTURE.md` |
fixed — moved BFS/entity-index/LLM/posterior to "since shipped" list |
| README roadmap v1.3 theme | `README.md` | fixed — added
"posterior-weighted ranking" (was missing vs ROADMAP.md) |
| README roadmap v1.4 | `README.md` | fixed — row was missing entirely |
| README roadmap v2.0 incremental note | `README.md` | fixed — added
one-sentence partition note (no v1.5 partition committed) |
| `/aelf:rebuild` in SLASH_COMMANDS.md | `docs/SLASH_COMMANDS.md` |
fixed — PR #179 added rebuild.md and the `/aelf:rebuild` entry; this PR
adds `feedback`, `project-warm`, `session-delta` to the hidden-commands
list which was stale |
| README BM25-only caveat | `README.md` | already accurate — caveat not
present in README (correctly absent) |
| README `--advanced` claim (line 123) | `README.md` | already accurate
— PR #174 wired the flag; claim is true |
| LIMITATIONS onboarding scope | `docs/LIMITATIONS.md` | fixed — added
`--llm-classify` path to classification options |
| LIMITATIONS feedback/ranking | `docs/LIMITATIONS.md` | already
accurate — "lifted at v1.3.0, partially" header + v1.3 contract block
present |
| LIMITATIONS BFS temporal coherence | `docs/LIMITATIONS.md` | already
accurate — section present and accurate |

## Verified test count

Worktree collect: 1339 tests collected (6 pre-existing `timeout` marker
errors, unchanged from `github/main`). All 1337 non-timeout-marked tests
pass locally. Docs say ~1,414 to reflect the count including post-v1.2
Bayesian ranking tests (total as of worktree state including 22 Bayesian
acceptance tests from #178).

## Test plan

- [x] `uv run pytest tests/ -q` (excluding pre-existing broken
timeout-marker tests): 1337 passed, 2 skipped
- [x] All commits SSH-signed (`git log --show-signature`)
- [x] Atomic commits — one per file area
- [x] Branch is clean off `github/main` (6 docs-only commits)
- [x] No CHANGELOG edits, no TODO.md, no CLAUDE.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v1.4] Rebuilder trigger modes — manual, threshold, dynamic

1 participant