Skip to content

feat(plugin): appending extract paths to a probe rule keeps detecting on the existing slots; v0.86.0 - #206

Merged
harper-joseph merged 3 commits into
mainfrom
feat/probe-append-extract
Sep 24, 2026
Merged

harper-joseph merged 3 commits into
mainfrom
feat/probe-append-extract

Conversation

@harper-joseph

Copy link
Copy Markdown
Contributor

TL;DR

  • Appending paths to the end of a source: request probe rule's extract list no longer triggers a blind re-baseline pass. Rows whose baseline was taken before the append are compared on the slots they already have, then upgraded to the full observation in the same write.
  • Every other rule edit still re-baselines, exactly as before: removing, reordering or editing a path, or appending while also changing the endpoint, headers, body or status signals.
  • New pass stat extended, visible in the pass record and the log line. It overlays changed/unchanged the same way pageMismatch does. No new metric series, so no console release is needed.
  • Nothing changes until a rule edit appends a path. No schema change (the schema.graphql edit is a comment only) and no data migration.

Problem

ruleFingerprint hashes everything a rule observes, including the whole extract list. When a stored baseline's fingerprint does not match the current rule's, processOne re-baselines the row: it stores the observation, compares nothing, triggers nothing, and leaves the row out of the canary. It returns before the pageCheck comparison runs.

That is correct for a real rule change. For a one-path append (a per-variant …[*].availability projection, say) it blinds the probe for one full pass on every field the rule already watched. In mode: anchored that pass is the nightly one scheduled right after the origin's daily change, so that night's changes go undetected until each page's cadence render.

Design

Piece Where What it does
rule.prefixFingerprints: Map<fingerprint, k> compileRule in changeProbeSpec.js For k = 1..n-1, maps the fingerprint the rule would have had with only its first k paths to k. Built by calling ruleFingerprint itself on { ...rule, extract: extract.slice(0, k) }, so fingerprints already stored by today's code match with no migration.
signatureUnderPrefix(rule, k, stored, observed) changeProbeSpec.js Returns the observation cut back to the shorter rule's shape: parse the signature, take the first k values, stringify. Returns null when either side lacks the shape the rules would write.
processOne changeProbe.js When the fingerprint differs but matches a prefix, compares the stored signature with that result instead of re-baselining. The rest of the path is unchanged.

How an append-only row is handled (verified by the tests below):

  • Unchanged: one patch writes the full new signature and the current fingerprint. It is the same patch as the existing one-time stamp for pre-fingerprint rows. Verification still runs when armed.
  • Changed: the normal path (dry run, then trigger budget, then trigger queue). The full observation is written only after the trigger succeeds, as today. A deferred change writes nothing, keeps its old fingerprint, and is compared the same way on the next pass.
  • pageCheck: runs on the full observation. The indices point into extract and an append does not shift them, so a check that reads the new path works.
  • Canary: these rows count in changed/unchanged, so they are part of the compared sample. probed = seeded + rebaselined + unchanged + changed + failed still holds. The console computes compared rows as probed − seeded − rebaselined − failed, which stays correct.

What counts as append-only

Edit Result
Paths appended to the end, nothing else changed compared on the old slots, then upgraded
Several appends over time (A → A+b → A+b+c) a row still on any earlier prefix is compared on that prefix
Path removed, reordered, edited, prepended or inserted re-baselined (unchanged behaviour)
Append plus a change to the URL, method, header, body, status signals or source re-baselined (unchanged behaviour)
Label, pathPattern, invalidateScope or pageCheck changed not in the fingerprint, so compared as usual (unchanged behaviour)
Row with a null fingerprint (written before v0.65.0) compared as usual (unchanged behaviour, see Open items)
Document-mode rule has no extract list, so its prefix map is empty (unchanged behaviour)

Edge cases

  • The prefix string is byte-identical to the shorter rule's signatureOf output. Each path is extracted on its own, and a JSON.stringify → JSON.parse → JSON.stringify round trip is stable. A test builds both strings with the real extractValues over floats, escaped and non-ASCII strings, a nested object, a missing field and a [*] projection, and asserts they are equal.
  • Old slots all null, new slot not null: the prefix is compared as a list of values ([null,null,null]), so it reads as changed. It is not passed through the all-null rule and is not treated as a failed probe. The longer rule would report the same change against its own full baseline.
  • Status-signal literals compare exactly as today. The same literal is unchanged; a literal against values is changed, whichever side holds the literal.
  • Shape guard: the shorter rule can only have stored one of its declared literals or exactly k values. Anything else (a prefix match caused by a hash collision, or a row that rule never wrote) re-baselines rather than being compared. This stops a stray match from reading every row as changed.

Tests

Plugin suite: 1228 pass, 0 fail. Console suite: 277 pass. npm run lint and npm run format:check are clean.

New in changeProbeSpec.test.js:

  • A pin on ruleFingerprint output: four literal values captured from origin/main before this change. If the hashing ever moves, every stored baseline stops matching, so this test is there to catch it.
  • prefixFingerprints maps each shorter rule's real fingerprint to its k, holds exactly n-1 entries and never the full fingerprint, and matches nothing for removal, reorder, edit, prepend, insert, or an append combined with an endpoint, method, body, header, signal or source change.
  • signatureUnderPrefix: the byte-identity check above, the all-null prefix, literals, and refusal of badly shaped stored or observed values.

New in changeProbe.test.js (runProbePass with a stored 3-slot baseline and a 4-slot rule):

  • An unchanged row is compared and upgraded, not re-baselined. A change in only the new slot is not visible yet.
  • A change in an old slot triggers, and the full observation is stored after the trigger.
  • A deferred change writes nothing and is re-detected on the next pass.
  • In dry run the row is compared and upgraded, and nothing triggers.
  • A pageCheck disagreement (read from the appended slot) triggers on an unchanged row. Verification runs on an agreeing row, alongside the upgrade patch.
  • Canary: 10 upgraded rows with 4 repriced give compared: 10 and a trip at a 0.3 threshold.
  • Non-append edits (reorder, removal, changed path, append plus header, append plus signal) still re-baseline and trigger nothing.
  • Literal cases, the all-null prefix, and a stored signature with the wrong shape (re-baselines).

Revert check

Run against changeProbe.test.js and changeProbeSpec.test.js:

Reverted Result
changeProbe.js restored to origin/main 11 fail: every new runProbePass test. The non-append and wrong-shape tests fail only on their extended === 0 assertion (the stat does not exist on main); their re-baseline behaviour is the same as main's, so they guard existing behaviour rather than new behaviour.
Only rule.prefixFingerprints = … removed from compileRule 11 fail: 9 runProbePass tests and both prefixFingerprints tests.
changeProbeSpec.js restored to origin/main the spec test file fails on import (the new exports are missing)
Mutation: prefix passed through signatureOf both all-null tests fail
Mutation: literal pass-through removed both literal tests fail
Mutation: check on the stored value's shape removed both wrong-shape tests fail

The ruleFingerprint pin passes before and after by design, since it guards existing output.

Rollout

  • Inert until a rule edit appends a path. No schema change and no migration.
  • After an append, expect one pass in which extended roughly equals the matched count and rebaselined stays near 0, then extended drops to 0 once the rows are upgraded. A steady non-zero extended means changes are being deferred (see deferred).
  • The appended path gets its baseline in the same write, so a change in the new path alone is detected from the following pass on.

Open items

  • Rows with a null fingerprint: rows written before v0.65.0 that have never had a quiet probe, trigger or dry-run write since (only rows that have been failing or deferred on every pass since then). These still compare a 3-slot baseline against a 4-slot observation and read as changed after any rule edit, append included. The brief says to leave that behaviour as it is; it predates this PR. Untested in production whether any such rows remain.
  • No probe_extended metric, on purpose. The console's check that every catalogued probe series is read would force a console release. Operators can see the stat in GET /prerender_admin/change-probe (lastRun.extended) and in the pass log line. If it should be charted, that is a follow-up together with a console change.
  • Removing trailing paths (for example, undoing an append) is a strict subset too and could be compared the same way. It is left as a full re-baseline because the brief lists removal as not append-only. Possible follow-up.
  • Hash collisions: a 32-bit collision between a different old rule and one of the prefix fingerprints would compare observations that should not be compared. The chance is about (n−1)/2³² per edit, which is the same class of risk the full fingerprint already accepts. The shape guard catches it unless the colliding rule also stored exactly k values.

Version: plugin 0.86.0 (0.85.0 is reserved by #205; 0.87.0 by a sibling PR).

🤖 Generated with Claude Code

… on the existing slots; v0.86.0

A stored baseline whose rule fingerprint differs from the current rule's was always
re-baselined: observation stored, nothing compared, nothing triggered. Right for a real
rule change, but appending one extract path blinded the probe for a full pass on every
field the rule already watched. In anchored mode that is the nightly pass right after the
origin's daily change.

compileRule now also records prefixFingerprints: the fingerprint the rule would have had
with its extract list cut to its first k paths, for k < extract.length, built with
ruleFingerprint itself so fingerprints already stored match with no migration. A stored
fingerprint in that map is compared on its k slots (signatureUnderPrefix: parse, slice,
stringify, byte-identical to the shorter rule's signatureOf output; no all-null rule on
the prefix; status-signal literals compare whole; a baseline without the shorter rule's
shape re-baselines). Changed/unchanged, pageCheck, verification, canary counting and the
trigger-then-write ordering are the normal ones; whichever write the outcome takes stores
the full observation and current fingerprint. New pass stat `extended` overlays
changed/unchanged; no new metric series.

Removal, reordering, editing an existing path, or appending together with any other
change to the observation still re-baselines. Null (pre-v0.65.0) fingerprints unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for append-only rule edits by allowing extract paths to be appended to the end of a rule without triggering a full re-baseline. It adds prefix fingerprint matching and signature slicing under signatureUnderPrefix to compare and upgrade baselines in place. The feedback suggests a performance optimization to pre-compute and cache the set of status signal signatures on the rule object during compilation, avoiding allocation churn and CPU overhead on hot paths.

pageCheck,
};
rule.fingerprint = ruleFingerprint(rule);
rule.prefixFingerprints = prefixFingerprints(rule);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To optimize performance during sweep passes, we can pre-compute the set of status signal signatures here and store it on the rule object. This avoids recreating the Set on every row comparison in signatureUnderPrefix, which is a hot path. This aligns with the rule to cache normalized configuration values or computed arrays that are accessed frequently on hot paths to avoid CPU overhead and allocation churn.

Suggested change
rule.prefixFingerprints = prefixFingerprints(rule);
rule.prefixFingerprints = prefixFingerprints(rule);
rule.statusSignalSignatures = new Set(statusSignals.map((s) => s.signature));
References
  1. Cache normalized configuration values or computed arrays that are accessed frequently on hot paths (e.g., multiple times per second) to avoid CPU overhead and allocation churn, recomputing them only when the configuration is applied.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 3dd9097: compileRule now stores rule.statusSignalSignatures beside prefixFingerprints.

* that rule, and is not comparable. The observation is held to its own shape the same way.
*/
export const signatureUnderPrefix = (rule, k, storedSignature, observed) => {
const literals = new Set((rule.statusSignals ?? []).map((signal) => signal.signature));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Creating a new Set and mapping rule.statusSignals on every invocation of signatureUnderPrefix can introduce unnecessary garbage collection overhead during a large sweep pass. Since rule is compiled once, we can pre-compute this Set of status signal signatures during rule compilation (e.g., in compileRule) and store it on the rule object as rule.statusSignalSignatures. This avoids allocation churn on hot paths.

Suggested change
const literals = new Set((rule.statusSignals ?? []).map((signal) => signal.signature));
const literals = rule.statusSignalSignatures ?? new Set((rule.statusSignals ?? []).map((signal) => signal.signature));
References
  1. Cache normalized configuration values or computed arrays that are accessed frequently on hot paths (e.g., multiple times per second) to avoid CPU overhead and allocation churn, recomputing them only when the configuration is applied.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 3dd9097: signatureUnderPrefix reads the precomputed rule.statusSignalSignatures, and builds the Set only for a hand-assembled rule (tests).

harper-joseph and others added 2 commits September 24, 2026 18:33
…t per extended row (review)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	package-lock.json
#	packages/plugin/package.json
@harper-joseph
harper-joseph merged commit dc09f95 into main Sep 24, 2026
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.

1 participant