Documentation
¶
Overview ¶
Package snapshot captures one complete doppel analysis run as plain data, so that two runs can be compared.
Nothing else in the pipeline has a notion of "a run": comparator compares two functions, analyzer compares two fingerprints, reporter renders one result. A Snapshot is the missing noun — everything an impact comparison needs about a corpus at a point in time, and deliberately nothing more. It is derived output, never an input to any score.
Three rules govern the schema, all load-bearing:
Determinism. The repo's invariant is that an unchanged tree produces byte-identical output, so every map is flattened into a sorted slice before it reaches JSON and every slice has a total order. encoding/json does sort map keys, so a map field would marshal deterministically too; sorted slices are used anyway so the ordering is visible in the type rather than an emergent property of the encoder, and so the Go code that reads a snapshot sees the same order the file does.
Identity over position. A diff is only meaningful if a function can be recognised across two runs, so units and pairs are keyed by name and never by index. Indices shift when any file changes, which is exactly the case a diff exists to describe. For the same reason paths are stored relative to the analysis root and slash-separated: `doppel analyze .` and a hook run rooted at an absolute cwd must produce comparable snapshots.
No wall-clock, no environment. A timestamp, hostname or absolute root inside a Snapshot would break byte-identical reproducibility the moment --format json exists. Those belong to the baseline file wrapper, which is never compared.
Index ¶
Constants ¶
const Schema = 2
Schema is the snapshot format version. Bump it whenever a field's meaning changes, so an older baseline is discarded rather than misread.
2 dropped every field no consumer read. A snapshot is diffed and rendered, never browsed, so a field nothing reads is pure weight in a file the Stop hook rewrites and re-parses on every turn.
Variables ¶
This section is empty.
Functions ¶
func Digest ¶
func Digest(fp fingerprint.Fingerprint) string
Digest hashes a fingerprint into a short hex string: the exact, corpus-independent "this body changed" bit, and the most attributable signal a diff has.
All four fingerprint fields are already sorted and deduped by fingerprint.Build, so the hash is deterministic without any extra ordering work. The zero fingerprint (a declaration with no body) digests to the empty string rather than to a hash value, mirroring the rule that a zero fingerprint never matches anything: two body-less units must not be reported as having identical bodies.
Types ¶
type Delta ¶
type Delta struct {
// Comparable is false when the two snapshots answer different questions.
// Callers should say so and stop, not report a partial diff.
Comparable bool `json:"comparable"`
Reason string `json:"reason,omitempty"`
FunctionsBefore int `json:"functionsBefore"`
FunctionsAfter int `json:"functionsAfter"`
PairsBefore int `json:"pairsBefore"`
PairsAfter int `json:"pairsAfter"`
MergeBefore int `json:"mergeWorthyBefore"`
MergeAfter int `json:"mergeWorthyAfter"`
UnitsAdded []Unit `json:"unitsAdded,omitempty"` // sorted by key
UnitsRemoved []Unit `json:"unitsRemoved,omitempty"` // sorted by key
BodiesChanged []Unit `json:"bodiesChanged,omitempty"`
PairsAdded []PairChange `json:"pairsAdded,omitempty"` // sorted: attributable first, then score desc
PairsRemoved []PairChange `json:"pairsRemoved,omitempty"` // sorted: attributable first, then score desc
Drift []Drift `json:"drift,omitempty"`
}
Delta is what changed between two snapshots.
The fields are ordered by how much they can be trusted, and the renderer leads with the same order.
UnitsAdded, UnitsRemoved and BodiesChanged are the solid ground: they come from names and from the fingerprint digest, both of which depend only on a function's own AST. If a body digest moved, that function was edited, full stop.
PairsAdded and PairsRemoved are one tier down. Retrieval keeps a bounded number of neighbours per function, so a pair can enter or leave the candidate set without either side being touched. Each pair change therefore carries an Attributable bit saying whether at least one of its two sides is in the touched set; the renderer leads with the attributable ones, because those are the changes this session actually caused.
Everything else corpus-relative — role changes, caller and callee counts, overlap movement, tag totals — is deliberately absent. Those move when code nobody touched moves, and reporting them would be reporting the session for something it did not do.
func Diff ¶
Diff compares a baseline against a later run.
Incomparability is a result, not an error: the hook has exactly one behaviour for every version of "these cannot be compared", and returning an error would tempt a caller into exiting non-zero over it. A mismatched schema, build, ontology or param set means the two runs measured different things, and a diff across them would be confidently wrong rather than merely unavailable.
func (Delta) AttributablePairs ¶
func (d Delta) AttributablePairs() (added, removed []PairChange)
AttributablePairs returns the added and removed pairs this session's edits actually caused, which is what a report should lead with.
func (Delta) Empty ¶
Empty reports whether the delta says nothing worth telling anyone. A hook that renders an empty delta should emit no output at all: a "nothing changed" line after every turn is worse than silence.
func (Delta) Touched ¶
Touched reports whether a pair change can be attributed to an edit in this session, rather than to corpus drift. Under an unfiltered snapshot every added or removed pair should be attributable; one that is not indicates the snapshot was taken with a top-N or struct-min filter applied.
type Drift ¶
type Drift struct {
A string `json:"a"`
B string `json:"b"`
ScoreBefore float64 `json:"scoreBefore"`
ScoreAfter float64 `json:"scoreAfter"`
OverlapBefore float64 `json:"overlapBefore"`
OverlapAfter float64 `json:"overlapAfter"`
MergeWorthyBefore bool `json:"mergeWorthyBefore"`
MergeWorthyAfter bool `json:"mergeWorthyAfter"`
Attributable bool `json:"attributable"` // at least one side's body changed
}
Drift is a pair present in both runs whose standing moved.
"Standing" is deliberately wider than "score". Merge-worthiness is gated on overlap, not on shape score, so a pair can cross into or out of merge-worthy with its score untouched. Admitting only score movement would make exactly the most consequential drift — the kind that changes what you should do about a pair — the kind that never gets recorded.
func (Drift) CrossedGate ¶
CrossedGate reports whether this pair changed merge-worthiness. It is the only drift that changes a decision, so it is what a short report leads with.
func (Drift) ScoreMoved ¶
ScoreMoved is the absolute shape-score movement.
type Pair ¶
type Pair struct {
A string `json:"a"`
B string `json:"b"`
Score float64 `json:"score"`
Overlap float64 `json:"overlap"` // corpus-relative
MergeWorthy bool `json:"mergeWorthy"` // half corpus-relative
}
Pair is one reported near-duplicate. A and B are Unit keys, ordered A < B so a pair has exactly one spelling and can be matched across runs.
Score is corpus-independent: fingerprint.Similarity reads two fingerprints and nothing else. Overlap is corpus-weighted through the information content of this run's tag counts, and MergeWorthy is half so — the signal count is corpus-independent but the 0.4 overlap gate is not.
Earlier schemas also carried the four fingerprint.Breakdown components and the evidence Reasons strings. Neither was ever read back: the text report renders both straight off analyzer.SimilarPair, never through a snapshot. The Reasons in particular were a quarter of a baseline's bytes — free-text English restating counts that move with corpus churn.
type PairChange ¶
type PairChange struct {
Pair
// Attributable is true when at least one side was added or had its body
// changed. When false the pair moved because retrieval re-ranked around it,
// which is corpus churn rather than a consequence of the edit.
Attributable bool `json:"attributable"`
}
PairChange is a pair that entered or left the candidate set, carrying whether the change can be traced to a function this session actually edited.
type Params ¶
type Params struct {
Threshold float64 `json:"threshold"`
Top int `json:"top"`
MinNodes int `json:"minNodes"`
StructMin float64 `json:"structMin"`
ChannelK int `json:"channelK"`
MaxPerFunc int `json:"maxPerFunc"`
TestsMode string `json:"testsMode"`
}
Params records the knobs a run used. Diff compares them because every doppel score is corpus-relative: a baseline taken at a different threshold or min-nodes is not an earlier answer to the same question, it is an answer to a different one.
type Snapshot ¶
type Snapshot struct {
Schema int `json:"schema"`
Doppel string `json:"doppel"` // doppel build version
Ontology string `json:"ontology"` // ontology.Version the run reasoned with
Params Params `json:"params"`
Functions int `json:"functions"`
Concepts []TagCount `json:"concepts"` // sorted by tag
Roles []RoleCount `json:"roles"` // sorted by role
Units []Unit `json:"units"` // sorted by key
Pairs []Pair `json:"pairs"` // sorted by score desc, then a, then b
}
Snapshot is one full analysis run.
A snapshot intended for diffing is built from the retrieved candidate set before the report's presentation cutoffs — no top-N truncation, no per-function diversity cap, no struct-min filter. Those cutoffs drop a pair for reasons that have nothing to do with any edit, so applying them first would manufacture differences; they belong at render time.
Removing them narrows the noise but does not eliminate it, and the schema is honest about which. Retrieval keeps each function's top-K neighbours per channel, weighted by corpus rarity, so pair membership is corpus-relative: adding code elsewhere can push a pair out of a function's channel budget without either body changing. That is why Diff separates a pair change it can attribute to a fingerprint that actually moved from one it cannot — see Delta.
func Build ¶
func Build(units []parser.CodeUnit, docs []concepter.ConceptDoc, pairs []analyzer.SimilarPair, tagCounts map[ontology.TermID]int, root, version string, p Params) Snapshot
Build assembles a Snapshot from one pipeline run.
docs[i] must describe units[i] and pairs must carry AIdx/BIdx into units: that positional alignment is the pipeline's existing contract, and resolving it by name instead has already caused one silent-miss bug in this codebase. Build converts to names once, here, at the boundary where positions stop being meaningful.
func (Snapshot) MergeWorthy ¶
MergeWorthy counts the pairs the run judged worth merging.
type Unit ¶
type Unit struct {
Key string `json:"key"` // stable cross-run identity; see unitKeys
Package string `json:"package"`
Name string `json:"name"`
File string `json:"file"` // relative to root, slash-separated
Line int `json:"line"` // display only, never diffed
Patterns []string `json:"patterns,omitempty"`
Digest string `json:"digest"` // fingerprint hash: the exact "body changed" bit
Role string `json:"role"` // corpus-relative; documented output only
}
Unit is one function or method as this run saw it.
Only what a consumer reads is kept. Key and Digest are corpus-independent — they depend on this function's own AST alone — and together they are the whole of what Diff may claim: Key recognises a function across runs, Digest is the exact "this body changed" bit. Package and Patterns feed the concept inventory, File and Line locate a finding for a human, and Line is display only: inserting anything above a function shifts it.
Role is corpus-relative and no internal consumer reads it. It survives because `analyze --format json` documents it, not because anything here needs it — see the --format row in README.md before removing it.
Earlier schemas also carried Qualified, Exported, Receiver, Nodes, Callers and Callees. Nothing ever read them.