Weight settle votes, backfill anchored paths
Epic 6659: Project memory, open the pipe, honest trust. This is child C2 (improvement 2, PRD S2). It builds on C0's plan gate. C4 depends on the restore branch built here.
Sources: PRD (Task 6660) §3.2, and Architecture (Task 6661) §1 C2, §2.2, §2.3 and §2.5.
## Original requirement (verbatim)
"It is important that all memory related mechanism stays behind the feature flag on the billing plan." … "Each later subtask ships its change inside this gate and adds a test proving the mechanism is inert with the term off."
"2. Weighted trust: a verified flag beats a reflexive confirm. `votes.py` 24–38, `nightly.py::trust_of`, `routes/agent_memory.py::record_settle_votes`: a settle confirm for a unit that was only `injected` in that task weighs 0.25; a flag carrying a note, a `stale` verdict, or a merged-PR citation weighs 1.0; one flag with a note drops `injectable` until a later evidence-backed confirm. Backfill the gotcha/qa path auto-fill (`bundle.py` 48–63, Task 6298) over existing units so the stale verifier can judge units like mem-2 (paths empty, test file named in prose). Done means: with the formula, mem-2's current 63 confirms + 2 flags no longer keeps it injectable once one flag carries a repro note; the backfill gives mem-2 a path and the next verify pass marks it stale."
## Acceptance criteria (verbatim, PRD §3.2)
- **S2-1 Vote weight.** Weights are derived from evidence:
- confirm, weight **0.25**: a settle confirm for a unit whose only event in that task is `injected`, on any channel;
- confirm, weight **1.0**: a confirm backed by a use event in that task (read, followed, or non-provisional cited); an explicit `memory_feedback` confirm; a merged-PR citation;
- flag, weight **1.0**: a flag with a note, a `stale` verdict, and every other flag. Today all flags weigh 1, so none is downgraded.
Trust is `(Σw_confirm+1)/(Σw_confirm+Σw_flag+2)`. Weights are recomputed from events on each retrust, so votes cast before deploy are reweighted too.
- **S2-2 One flag with a note drops `injectable`.** A unit drops `injectable` immediately on a flag with a non-empty note or a stale verdict, whatever its trust. It regains `injectable` only after a later confirm with weight 1.0 and trust ≥ floor.
- **S2-3 Path backfill.** Every existing gotcha/qa unit with empty `paths` gets the paths `gates.inferred_paths` would give it at ingest. Each backfilled unit is anchored at the repository state when the unit was created, not at today's sha, so the next verify pass judges change since the unit was written and marks a changed unit `stale` instead of dropping its paths. The backfill is idempotent and additive. If a unit's origin commit cannot be determined, the unit is backfilled with paths and no anchor, which is today's behaviour, and the backfill counts and reports it.
- **S2-4 Inert** (S0-7): with the term off, retrust and backfill leave rows unchanged.
- **Q3** Single-home: limits and weights are named constants in `apps/api/app/project_memory/vocabulary.py`, never repeated.
## Approach (Architect §1 C2; settled)
- **One weighting home: `votes.py`.**
- `vote_weight_clause()` is a SQL CASE over `MemoryVote` joined to `MemoryEvent`. It gives 1.0 when `verdict='flag'`; 1.0 when `source in ('tool','citation')`; 1.0 when a non-provisional use event (`events.use_event_clause`) exists for (unit, task); and 0.25 otherwise.
- `weighted_trust(session, unit_ids)` computes trust from those weights.
- Add the constants `CONFIRM_WEIGHT_REFLEXIVE = 0.25` and `VOTE_WEIGHT_FULL = 1.0` to `vocabulary.py`.
- `nightly.retrust` (`:192-224`) uses it for every unit in `scope`.
- `cast_vote` (`votes.py:32-38`) recomputes the one unit through the same function instead of doing incremental arithmetic.
- The `confirms`/`flags` integer counters stay raw counts for display.
- **Noted flag.** A `memory_feedback` flag with a non-empty note, or a stale verdict, sets `injectable=False` and `status_reason = FLAGGED_WITH_NOTE` (new constant `"flagged with a note"`). Add `FLAGGED_WITH_NOTE` to C0's `RESTORABLE_REASONS`. `cast_vote` gains `note: str | None`, and `routes/agent_memory.py:312` passes it. The note is not persisted, and no column is added.
- A noted flag never replaces an existing restorable reason.
- **Restore branch (this child owns it; C4 relies on it).** In `cast_vote`, a confirm whose weight is 1.0 on a unit whose `status_reason in RESTORABLE_REASONS` clears the reason and sets `injectable=True` iff `votes.restorable_injectable(unit)`, the C0 predicate. Keep it generic over `RESTORABLE_REASONS`, with no per-reason code, so C4 only adds a reason.
- **Path backfill.** Add a nightly step `backfill_paths(scope)`.
- It selects gotcha/qa units with `paths = '{}'` and status in (accepted, stale), and sets `paths = gates.inferred_paths(title + body)` where that is non-empty.
- It logs `backfilled=N unresolved=M`.
- It is idempotent, needs no marker column, and never changes status.
- **Creation-time anchor (wire pinned in Architect §2.5).**
- CP: `capture.verify_worklist` (`:204-223`) adds `"anchor_at": unit.created_at.isoformat()` when `anchor_sha IS NULL`.
- Node: in `tools/project_memory_node.py::verdict_for` (`:360-406`), when `anchor_sha` is absent and `anchor_at` is present, resolve `git rev-list -1 --first-parent --before=<anchor_at> <head>`. Use the result as the numstat anchor and return `"anchor_sha"` in the verdict row.
- CP: `MemoryVerdictIn` (`routes/agent.py:7551`) gains an optional `anchor_sha`. In `capture.py:487-519`, a verdict carrying `anchor_sha` for an unanchored unit stores it BEFORE branching, so the stale branch marks `stale` instead of dropping paths, and the fresh branch keeps that anchor.
- Fallback: if the commit is unresolvable, send no `anchor_sha`; today's behaviour stands. The CP logs `memory verdict unanchored` with a per-batch count.
- Old nodes ignore `anchor_at`.
- The node half reaches nodes via the jaah package published by Jaah Deploy.
Shared edit points (rebase expected): `votes.cast_vote` (C4 adds a reason that this branch restores), `vocabulary.RESTORABLE_REASONS` (C4 also adds to it), `nightly` step list (C3 edits `events.use_pairs`), and `routes/agent_memory.py`.
Credentials & reach (verbatim rows from Architecture §4):
- All unit/API/web tests and `./dc check full` | Nothing beyond the worktree and its `./dc` stack's Postgres | verified: worktree present, gate runs in-slot.
- Dev console checks (S2-3), operator sign-in on `https://app.dev.jaah.ai` | `jaah/debug/main/operator-login` | verified: on the roster, `task_credential_get` succeeded, and the dev health probe returned 200.
- PR merge and Jaah Deploy watch (E1) | `jaah-forge` (authenticated per task) | verified.
- Dev oracles needing the new node code (S2-3 dev) | Dev nodes running a jaah package built from the C2 merge | deferred: the package does not exist until this child merges and Jaah Deploy publishes it. Confirm the node's package version on the console Nodes page before the dev check. The API/tool tests stand as the oracle meanwhile.
Out of scope:
- From the epic, verbatim: "configuring the production memory model credential (an operator action in Settings › Runners › Project memory, not code); retiring mem-2/mem-3 and purging mem-7834 (operator does it in the console); any change to the OMC notepad/project-memory tier; new embedding providers."
- Do NOT retire or edit mem-2/mem-3 by code, migration or script. The backfill must not change their status except through the normal verify verdict.
- Do NOT persist feedback notes and add no column. Make no migration.
- Do NOT change which settle votes get minted (that is C3) or `use_pairs` (also C3).
- Do NOT add `NEEDS_REPRO` (that is C4).
- Do NOT change embeddings code.
Done means:
- S2-1 — unit tests of `trust_of`/`retrust` with seeded votes and events match hand-computed values, including pre-deploy votes reweighted on retrust.
- S2-2 — a test seeds a unit with 63 reflexive settle confirms and 2 plain flags and asserts it is injectable. A `memory_feedback` flag with a repro note makes it `injectable=false`. One more reflexive confirm leaves it false. One read-backed confirm makes it true.
- S2-3 — a test seeds a repo containing `tools/tests/test_context_router_dedupe.py` and creates a unit whose body names it. It then rewrites the file beyond `STALE_CHANGE_SHARE`, runs the backfill, runs `verdict_for`, and applies the verdict. It asserts the unit got the path, an anchor resolved from `created_at`, and `status == "stale"`. A backfill that gives paths with no anchor (so the verifier drops them) FAILS. An unresolvable-anchor case is counted and logged. On dev, if mem-2 is not yet retired: open Project › Memory and search mem-2. Observe the test file in the Paths column and status Stale after the next verify pass (deferred until the jaah package with this change is on a dev node). If it is retired, the test stands as the oracle.
- S2-4 — an inert test using `plan_off_project` shows retrust and backfill leave its rows byte-identical. Name it in the PR body.
- Q3 — the weights exist only as `vocabulary.py` constants; grep shows no literal 0.25 elsewhere in `project_memory/`.
- E1 — the PR is merged to develop through auto-merge with `./dc check full` green, and the Jaah Deploy run succeeded (or was superseded).
Outcome
Weighted settle votes (0.25 reflexive), noted-flag hold + restore, nightly gotcha/qa path backfill anchored at creation. Full gate green; PR #5627 merged (c14a05eb8), Jaah Deploy OK. Dev mem-2 check deferred to node package; follow-up draft #6757.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)