Two people can look at the same chart and agree that something happened, yet disagree about when it happened and which rule identified it. Structure Lab is the part of Helmsman that turns that disagreement into something we can version, execute and compare.

This article follows one vague claim through definition, annotation and evaluation, and shows what happens when a new version replaces it.

One sentence, three hidden decisions

The claim is: “Price keeps bouncing off $100. Support just broke.”

A support level is a price where declines have repeatedly stopped. If a stock falls to $100 on Monday, Wednesday and Friday and turns back up each time, traders call $100 support. “Support broke” means the price went convincingly below it.

As a specification, the sentence leaves three decisions open:

  1. Which low is the level? The bounces in our example stop at $100.00, $100.25 and $100.10.
  2. What counts as broken? Does a brief dip to $99.60 count, or one hourly close at $99.90, or only a close well below the level?
  3. When was it knowable? A low only looks like a turning point after prices move away from it. Marking the turn at the low itself uses information nobody had yet.

A chart answers these implicitly, differently for each viewer. The lab makes each answer an explicit parameter.

The definition, written down

Structure Lab reads candles. A candle holds the open, high, low and close prices for one time bucket. For example, an hourly candle might open at $100.50, trade between $99.70 and $100.70, and close at $99.90.

A definition is a JSON object with fixed sections. This is the real format with invented values, not the lab’s defaults or any registered definition:

{
  "swing": { "fractalBars": 2, "externalMode": "bos_only" },
  "acceptance": { "mode": "close", "atrLength": 10, "kAtr": 0.5, "nCloses": 2 },
  "fvg": { "enabled": false, "minAtr": 0.5, "mitigation": "full" },
  "ob": { "enabled": false, "displacementKAtr": 2, "requireFvg": false,
          "zone": "body", "mitigation": "full" },
  "display": { "showInternalSwings": false, "extendUnmitigatedBoxes": false }
}

swing answers the first question. A swing low is a bar whose low is not beaten by the fractalBars bars on either side, so with a value of 2 the $100.00 low is confirmed only after two more hourly bars close. That also settles the third question: each detected event records where it sits and the bar at which it became knowable. externalMode chooses which swing a break must clear: the one the current structure was built from, not every minor low.

acceptance answers the second question. A break can require one close beyond the level, a close beyond it by a margin, a candle body fully beyond it, both of those, or several consecutive closes. The margin is measured in ATR (average true range), a rough measure of how far the price typically moves in one bar. With an hourly ATR of $1.10 and a kAtr of 0.5, a qualifying close must be more than $0.55 below a $100 level, so below $99.45. The other sections control two further chart patterns, both disabled here.

The format had already decided one thing: every acceptance mode uses closes or candle bodies, so a brief dip below the level never counts.

Three versions over the same candles

I ran the lab’s real TypeScript detector over 40 synthetic hourly candles built for this article, numbered from 0. They contain three bounces near $100, a dip to $99.60 at bar 24 that closes at $100.40, and a close of $99.90 at bar 28. The price then recovers above $100 before closing at $99.20 at bar 32 and $98.80 at bar 33. The three versions differ only in acceptance.mode:

Version Break rule Breaks annotated Hash prefix
v1 One close below Bar 28 through $100.00; bar 32 through $99.70 ac84e9e5c10e
v2 Close below by 0.5 × ATR Bar 32 through $100.00 8dff26033129
v3 Two consecutive closes below Bar 33 through $100.00 4cf5acd7997e

No version flagged the dip at bar 24. At bar 28 the ATR was about $1.13, so v2 needed a close below roughly $99.43. v1 annotated two events because, after its first break, the next confirmed swing low at $99.70 became the new level. Evaluation is built from those events, so the versions already disagree before any scoring.

All three chose the same level, $100.00, the first confirmed swing low; the later bounces and the dip became minor swings. The sentence described support as a zone touched several times. The definition narrows it to one price, and that narrowing is now visible.

Annotations are derived, not stored

The break markers on the chart are annotations, not saved records. Detection is a pure function of the candles and the definition. The page reruns it after every parameter change and redraws the detection layer from scratch. Showing or hiding a layer is page state, not part of the definition.

So when the definition changes, nothing needs migrating: load v1 again and its markers reappear exactly. Hand-drawn lines and notes are the exception. The reviewed revision did not keep them after a reload, an open gap in the lab’s notes, so manual markings belong to no definition version.

Versions are rows, not edits

REGISTER freezes the current definition into a registry table. I ran the real registry route against a disposable local Postgres 16 database:

  • v1 was stored with a SHA-256 hash of its canonical JSON: keys sorted recursively, no whitespace.
  • The same definition with every key in reverse order, submitted under another name, returned the existing row with deduped: true. Identity comes from the configuration, not the label.
  • v2 and v3 became new rows, each with a parent_id pointing to the version it was forked from.
  • A database trigger refused UPDATE and DELETE: “a tweak = a new row with parent_id”.

Only the API computes hashes. JavaScript writes the number one as 1 and Python writes the float as 1.0, so client-side hashing could give one definition two identities.

The replay also showed the cost of hashing bytes rather than behavior. Changing kAtr on v1 does nothing because the close rule ignores it, and a display flag on v2 changes only the drawing. Both copies detected exactly what their originals did, yet each got a new hash and row. The team had met this in newer optional sections, where the evaluator rejects unused parameters because they “would fork the registry’s hash/dedupe space while changing nothing”. The lab always submits the core sections whole, so that rule does not reach them. The error is cautious: a duplicate starts with no evidence rather than inheriting evidence it did not earn.

Evidence belongs to one version and one engine

A separate Python evaluator writes results to an append-only table. Each row belongs to one definition, instrument, timeframe, monthly window and code version, which is a hash of the detector and evaluation source that actually ran. A re-run never overwrites a row; it recomputes the result and fails if it differs.

A month counts as out-of-sample only if it starts after the definition was registered. Out-of-sample data is data a rule could not have been tuned on. A definition registered on March 10 can use April onward as a test, while January to March is history that might have shaped it. A new version starts that clock again.

To test the link, I hand-inserted placeholder evaluation rows for v1, with no scoreable events, then called the real evidence query that the promotion gate uses:

Rows present Evidence query result
v1: January to March, one engine version in_sample_only, 3 months
v2, forked from v1 none
v2 with only a display flag changed none
v1 plus one month under a newer engine version in_sample_only, 1 month (newest version only)

Evidence did not pass from v1 to its children, and a newer engine version replaced earlier rows in the answer without deleting them. The leaderboard showed v1’s family size as five: every registered version sharing its root. The more variants we try, the larger the margin a leader needs, so the two no-op duplicates inflate that count cautiously.

Scoping by code version came from a real failure. On July 7, monthly event counts exposed a detector bug: after certain trend changes the state machine had no level left to watch and stopped emitting events for good. One dense configuration had gone silent for months of recorded data without an error. We fixed the TypeScript and Python detectors together and left the old engine’s rows under their old code version. Rankings never mix engine versions, because a detector fix changes what an event means.

Promotion stays a separate step

Nothing in the lab watches a market. A definition becomes monitoring only when someone promotes it through the Composer into the Helmsman workspace, carrying its hash forward, so a changed definition creates a new monitor rather than altering an existing one. A later article in this series covers that boundary. When the promotion code was written, its header recorded that no definition had yet survived out-of-sample testing.

Evidence and limits

  • Implementation: the Structure Lab detector, registry route and evidence query from July 2026, plus the Python evaluator’s table definition.
  • Replay: 40 synthetic candles and five invented definitions, run locally on September 22, 2026 against a disposable database. It shows versioning and attribution, not predictive value. Evaluation rows were hand-inserted placeholders; neither the Python evaluator nor the Python detector ran.
  • Historical receipts: the July 7 detector fix, from its commit record. In a July deployment review the deployed lab could not reach its recorded candles; it showed the error and offered synthetic candles instead of substituting them silently.
  • Not covered: real definitions, parameter values, market data, evaluation results and the ranking metric.

A chart argument becomes testable when it is a definition another implementation can reproduce, with its own identity, its own evidence and no quiet edits. The lab does not make the idea right. It makes the idea specific enough to be proved wrong.