feat(retrieval): explicit pins — a working set that always enters the pack - #666
Conversation
… pack when you are deep in one task there are three or four artifacts that should be in front of the agent every turn — the spec, the decision that constrains the design, the claim saying why the obvious approach was already rejected. whether they appear is currently up to the ranker, and a ranker optimising for the query drops them the moment the conversation moves. hot_memory and salience are the implicit version of this and decay exactly when a long task needs them not to. pinned claims and pages now lead the pack. they are capped at retrieval.pins.budget_share (default 0.3) so pins can never starve retrieval, and de-duplicated against what retrieval already found so a pinned artifact that also ranked does not take two slots. pin order is the user's stated priority, so the tail is dropped at the budget rather than silently reordered. a pin is not a gate bypass. it points at an artifact that is already approved, asserts nothing new, and creates nothing durable — there is nothing for a reviewer to review, and an unapproved artifact cannot be pinned because it does not resolve. a pin is also not a permission. lifecycle and viewer scope are re-checked on every build: a pinned claim that is later superseded, archived or redacted stops being injected, as does a pinned page that is archived or one the scope filter hides. the pin records what to prefer, never a right to see it. this is the bug class that has been fixed a dozen times on other read surfaces, so it is pinned by tests here from the start rather than after. shared pins live in committed .vouch/pins.yaml so a team shares one working set and changes show up in review; --local keeps a personal set in gitignored .vouch/pins.local.yaml, using the same .gitignore backfill retrieval telemetry uses. --expires drops a pin automatically, applied on read rather than by rewriting the file, because building a context pack must not write. no kb.* method: the issue's own open question proposes that pinning is a human act and agents may only suggest, so there is nothing to register on the agent surfaces. Closes vouchdev#615
# Conflicts: # CHANGELOG.md
plind-junior
left a comment
There was a problem hiding this comment.
implements #615 faithfully — vouch pin / pins list / unpin, pins lead every pack within retrieval.pins.budget_share, committed shared set plus --local, expiry applied on read, and the issue's open question (agents pin?) resolved conservatively with no kb.* surface. the two invariants you asked to have reviewed (not a gate bypass, not a permission) are genuinely enforced in pinned_items and pinned by tests: lifecycle and scope are re-checked inside the loop before budget is consumed, the one-pin floor works, PinError renders cleanly, and the expiry-on-read byte-identical-file assertion is exactly the right test.
one blocking bug in --expires parsing and one non-blocking ordering nit — both anchored inline on the lines in question.
requesting changes for the --expires <iso-date> inversion: it silently no-ops a documented input. the fix and its regression test are small; everything else is ready.
parse_since returns iso input unchanged and only counts durations backwards, so mirroring its result around now was right for 7d and wrong for a date: --expires 2026-08-15 computed now + (now - 2026-08-15), a timestamp a month in the past. load_pins drops expired pins on read, so the pin vanished the moment it was written, with no error. iso input is now parsed directly and only durations are mirrored. a spec resolving to no bound (all, empty) is rejected rather than silently meaning never, which is already what omitting the flag does. also de-duplicate retrieval against pins on near-duplicate text, not just exact (type, id). _dedupe_near_duplicates runs before pins are injected, so it never compares a retrieved item to a pin, and the same knowledge stored under a second id could take a second slot. the comparison runs at the injection site rather than by moving that pass below it: the pass keeps the highest-scored member of a cluster, and pages_first multiplies a page's score past a pin's flat 1.0, so moving it would let retrieval evict a pin.
|
both fixed, and the second one differently than you suggested — the suggested move would have broken the invariant it was protecting. the
i also took the non-blocking nit you folded into the same the near-duplicate ordering — i did not move the pass. so i took the fallback you named: the pass stays where it is and pins de-duplicate against retrieval explicitly at the injection site, where the pin always wins. the heuristic (0.85 jaccard over the first 40 tokens) is now two named constants shared by both call sites so they cannot drift to different notions of "same".
|
Closes #615
when you are deep in one task there are three or four artifacts that should be in front of the agent every turn — the spec, the decision that constrains the design, the claim saying why the obvious approach was already rejected. whether they appear is up to the ranker, and a ranker optimising for the query drops them the moment the conversation moves.
the pinned claim now leads every pack, including one built from a query it shares no term with — which is the whole point, and what
test_pinned_artifact_leads_a_pack_it_would_not_have_enteredasserts.the two invariants i most want reviewed
a pin is not a gate bypass. it points at an artifact that is already approved, asserts nothing new, and creates nothing durable — so there is nothing for a reviewer to review. an unapproved artifact cannot be pinned at all because it does not resolve (
test_pinning_an_unknown_artifact_is_refused). the issue says this too; i am repeating it because "always enters the context pack" is the kind of phrase that should attract scrutiny.a pin is not a permission. lifecycle and viewer scope are re-checked on every build, not at pin time:
this is the "retracted content leaking into a read surface" bug class that has now been fixed on digest, salience, context, search, hot-memory, triage, verify — and twice on
explain_rankingafter i shipped it (#640, #651). so it is pinned by tests here from the start rather than after. a pin records what to prefer, never a right to see it.budget
pins are capped at
retrieval.pins.budget_share(default 0.3) of the pack budget, so they cannot starve retrieval. the tail is dropped at the cap rather than skipping a large pin to fit a later small one — pin order is the user's stated priority and silently reordering it would be worse than dropping the end. one pin always survives, even at a share of ~0, because a working set of zero is not what anyone asked for.pins are also de-duplicated against what retrieval already found, so a pinned artifact that also ranks takes one slot, not two.
storage
.vouch/pins.yamlis committed — a team shares one working set and changes to it show up in review like everything else.--localwrites.vouch/pins.local.yamland backfills.gitignoreusing the same helper shaperetrieval_eventsuses for telemetry. when an artifact is pinned in both, shared wins.--expires 7ddrops a pin automatically, and expiry is applied on read rather than by rewriting the file — building a context pack must never write.test_expired_pins_are_dropped_on_read_without_rewritingasserts the file is byte-identical afterwards.no
kb.*method — deliberatelythe issue's open question is whether agents may pin, and proposes: agents suggest, humans confirm. i took that answer, so there is nothing registered on the agent surfaces and no four-site registration. if you would rather agents manage their own pins,
kb.pin/kb.unpinis an additive follow-up rather than a rework — but i did not want to widen the write-ish surface on my own reading of an open question.one thing i had to fix while wiring the cli:
PinErrorwas escaping as a raw traceback because_cli_errorscatches an explicit tuple. it is registered there now, so it renders as a one-lineError: ...like every other domain error —test_cli_unknown_artifact_is_a_clean_errorasserts no traceback.tests
41 cases in
tests/test_pins.py: the pack-ordering behaviour, both "not a permission" families (lifecycle × archive/supersede, archived page, viewer scope), budget share including the ~0 edge and the defensive config coercions, expiry, local vs shared precedence, re-pinning, malformed pin rows and unreadable files, deleted-artifact skips, and the full cli round trip in text and json.verification
context.pygains 12 lines and no behaviour change when no pins are set — the pin file does not exist on a stock KB, sopinned_itemsreturns empty and the pack is byte-identical.