# Turning a chart argument into a testable definition

> One vague chart claim turned into three versioned definitions and replayed on synthetic data through Structure Lab's real detector and registry. The replay shows what a new version does to earlier annotations and evidence.

Published: 2026-09-22  
Updated: 2026-09-23  
Work period: July 2026  
Product: [Helmsman](https://adityadalmia.com/projects/helmsman/)  
Canonical HTML: https://adityadalmia.com/blog/helmsman-structure-lab/

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:

```json
{
  "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](https://adityadalmia.com/blog/helmsman-promotion-boundary/) 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.
