Bounded context reads and duplicate discovery
Tracked by pm-pshhry, pm-ydshl9, pm-bab3gb, pm-fx80w2, pm-gtw5zh, and pm-s8ybl9.
Project management is context management. Routine reads should pay for the requested evidence, and derived feedback should not grow with every candidate in a project's history. These primitives share SDK implementations across CLI, MCP, and package consumers.
Point reads
pm get <id> at standard depth reads the addressed item and its own history.
It does not enumerate unrelated items to compute child counts. --depth brief
and --fields id,title also omit claim-history work and heavy output facets.
The cost still depends on the addressed record, registered type directories,
and extension activation; this is not a constant-latency guarantee for arbitrary
extensions or unbounded individual history streams.
Request hierarchy work explicitly with --fields id,children, --tree, or a
deep/full container read. These paths preserve registered hierarchy semantics,
including inverse and custom relationship kinds, rather than treating only the
scalar parent field as authoritative. An omitted children facet carries a
restore selector in the omission receipt. Explicit deep/full and child-field
projections retain a zero-count rollup for an empty container.
Caller-carried output sessions retain only portable item identities in
next_state.seen_item_ids. Workspace activity such as _workspace remains
visible in full on subsequent reads. It cannot become an invalid item reference
or make the next read reject a state produced by the previous read.
The optional SQLite projection binds each FTS document to the corresponding
items.rowid. Maintenance resolves the indexed item identity and deletes the
single FTS row, avoiding a scan of the unindexed FTS id column on every insert,
update, or deletion. Derived index version 5 rejects older projections and
rebuilds them from authoritative metadata. Real SQLite query-plan tests retain
the old predicate as a negative control and verify search results after updates
and deletions. SQLite documents the distinction between
FTS columns and row identity.
Exact duplicate candidates
pm duplicates --status all --threshold 0.8 --json
pm duplicates --status open --exhaustive --json
The default algorithm orders title tokens by corpus frequency and uses a
Jaccard prefix join. For a set of size n and threshold t, a prefix of length
n - ceil(t*n) + 1 must intersect the similarly ordered prefix of every
qualifying set. The length filter rejects impossible token matches before
scoring. Exact normalized titles, issue codes, and empty-token matches retain
the canonical scorer's separate rules. This follows the established
all-pairs similarity-search approach.
Both modes retain exact recall relative to the canonical title scorer.
cost discloses algorithm, item_count, possible_pairs, candidate_pairs,
scored_pairs, pair_limit, and recall_guarantee. The one-million-candidate
safety ceiling remains enforced. Dense duplicate output or a zero threshold
can still require quadratic work; the command refuses without returning a
partial answer. Exact recall is not a claim that every semantic duplicate has
similar titles. Review the actual item evidence before merging or closing.
Packages with remote or custom stores can use the same kernel without building a filesystem tracker:
import { analyzeDuplicateItems } from "@unbrained/pm-cli/sdk/query";
const result = analyzeDuplicateItems([
{ id: "work-a", title: "Publish research protocol", type: "Task", status: "open" },
{ id: "work-b", title: "Publish research protocol", type: "Task", status: "closed" },
]);
console.log(result.clusters, result.cost);
The function preserves its input, validates unique nonempty identifiers, and
returns deterministic components and scored pair evidence. The filesystem
findDuplicateClusters, PmClient.duplicates, CLI and MCP adapters reuse it.
Record the algorithm and cost receipt in create-time duplicate-check evidence.
Feedback storage
Serving receipts retain the full candidate population in memory for final delivery validation. Persisted events retain only the first 256 candidate rows, with explicit population and omission counts. Only sampled ids observed in the final emitted result can train affinity. This deterministic sample has no unbiased-propensity claim. Unknown row properties are not persisted.
CONTEXT_USAGE_LIMITS publishes a 256 KiB physical ceiling, a 32 KiB individual
event ceiling, 2,048 retained events and a 30-day default horizon. Writers share
a cross-process lock. High-water compaction atomically replaces the ledger with
a suffix of at most half the byte ceiling, leaving append headroom. Readers
consume at most the byte ceiling even for an oversized legacy file. Custom
retention controls can tighten the limits; invalid controls fail before I/O.
Serving receipt storage reports actual written_bytes, ledger_bytes,
compacted, and lock_wait_ms. Written bytes count ledger data rather than lock
metadata or filesystem block allocation. The receipt is attached to results
through a non-JSON symbol and does not inflate normal CLI output. Storage is
derived and disposable; item history remains independent. Ledger access refuses
existing symbolic links, shared hard links, non-regular files, and a redirected
runtime directory. Appends use a validated descriptor with no-follow opening
where supported; replacement files are created exclusively with private
permissions. Compaction closes both data handles before replacing the ledger,
including on Windows.
Feedback persistence requires trusted, stable workspace directory entries and
ancestors. It is unsafe when an untrusted actor can concurrently replace those
entries: portable Node filesystem APIs do not pin ancestor directories through
the whole operation. The link checks reject existing redirections; the lock
coordinates cooperating SDK writers and is not a security boundary. Before
using an adversary-writable live workspace, set PM_CONTEXT_USAGE_DISABLED=1
to disable feedback reads and writes, or work in an isolated copy owned by the
caller. Public feedback API callers can also pass enabled: false.
Reproducible measurements
pnpm build
node scripts/bench/context-read-costs.mjs 10000 100000 1000000
node scripts/bench/point-read-costs.mjs 100 10000 100000
The script uses paired titles with common project vocabulary, compares a 1,000-item subset against exhaustive scoring, and runs four concurrent writers for 40 serves per tier in temporary storage. It asserts exact reference equality, expected pair counts, reachable byte ceilings, and append headroom. Property tests additionally vary thresholds, Unicode, punctuation, and issue identifiers.
The point-read script holds the addressed Epic constant while varying corpus
size. The public measureItemMetadataReadWork query SDK wrapper reports actual
item-store enumeration calls and returned metadata rows, including cache hits.
Ordinary reads must perform zero enumerations; an explicit child scan must
observe every generated item. A regression test deliberately enumerates and
discards the corpus before a normal read, proving that output shape alone cannot
satisfy this work gate. Measurements preserve operation failures, isolate
concurrent roots, include nested work, and release their observers on completion.
They cover item-store enumeration APIs rather than arbitrary extension I/O.
Fixture setup
registers all generated types, including Story, through the live SDK schema
and uses deterministic SDK execution for byte-reproducible workspace history.
The 2026-09-05 filesystem run observed the following timings (10 reads per projection, including feedback writes). The explicit scan includes the cold derived-index rebuild; these are host observations rather than latency gates.
| Corpus items | Standard p95 | Brief p95 | Fields p95 | Explicit full scan | Verified scan rows |
|---|---|---|---|---|---|
| 100 | 4.53 ms | 4.24 ms | 3.54 ms | 48 ms | 100 |
| 10,000 | 3.61 ms | 3.03 ms | 4.08 ms | 4.91 s | 10,000 |
| 100,000 | 3.69 ms | 11.00 ms | 3.11 ms | 48.59 s | 100,000 |
Measured on 2026-09-05 with Node 26.7.0; these are observations, not portable latency guarantees. Duplicate timings exclude filesystem ingestion.
| Items | Possible pairs | Scored pairs | Analysis time | Mean bytes written per serve | Compactions / serves | Final ledger bytes |
|---|---|---|---|---|---|---|
| 10,000 | 49,995,000 | 5,000 | 92 ms | 18,138 | 2 / 40 | 237,785 |
| 100,000 | 4,999,950,000 | 50,000 | 864 ms | 18,149 | 2 / 40 | 237,821 |
| 1,000,000 | 499,999,500,000 | 500,000 | 10,367 ms | 18,135 | 2 / 40 | 237,857 |
The same four-writer runs include full in-memory candidate validation and final delivery recording. Throughput therefore still falls with candidate population.
Lock-wait percentiles use the empirical nearest rank across all 40 serving receipts; these are four concurrent tasks sharing a cross-process lock, not a measurement of four separate OS processes.
| Candidate rows per serve | Serves/s/workspace | Minimum wait | p50 wait | p95 wait | Maximum wait |
|---|---|---|---|---|---|
| 10,000 | 46.73 | 3.11 ms | 25.15 ms | 84.97 ms | 105.66 ms |
| 100,000 | 20.82 | 18.29 ms | 50.10 ms | 193.86 ms | 229.60 ms |
| 1,000,000 | 1.92 | 198.10 ms | 809.52 ms | 1,367.67 ms | 1,622.77 ms |
Reference recall was 100% at every tier. The million-item batch does not meet the broader one-second project target; persistent incremental ingestion and analysis remain necessary for that target. Full candidate validation still costs O(candidate count) in memory even though persisted feedback is bounded. On the live 2,638-item corpus, default discovery completed after scoring 3,136 of 3,478,203 possible pairs; the prior algorithm refused at its safety ceiling.