Skip to content

The Spiral Laboratory

The Spiral Laboratory is where a situation becomes a test. You replay a recorded moment, step to the point that interests you, try things, write down what should hold, reduce it to the smallest case that still demonstrates it, and keep it. The library that results is what a new Spiral release and every domain change are measured against.

It is also the tuning loop: adjust the cost model — delay costs, noSoln outsourcing costs, travel factors, option times, quality settings — until Spiral’s decisions match the operation’s intent, then replay the same test and compare.

Open it from the landing screen (Spiral Laboratory, with an admin login), or from the entity editor. Prerequisites: a client dev or tuning environment — see Develop a client extension — and one or more tests in the client’s tests/ tree.

What the laboratory holds, and what it does not

Section titled “What the laboratory holds, and what it does not”

The laboratory browses the client’s tests/ tree (HELIX_TESTS_DIR, or <HELIX_CLIENT_PATH>/tests). Each directory is a stage of a test’s life:

StageWhat lives there
development/the bench — new tests, from scratch, from the vault, from a snapshot, or reduced by the minimiser. Not shared.
new/ready for work; a developer approves it into current
current/under development
regression/passing reliably — the suite a new Spiral or a changed domain must pass
challenge/to be retired or modified; leaves only to retired or current
future/aspired to, not yet believed passable
retired/kept for the record, and where a stale test belongs

Each card shows the test’s name, its comment, what a good run of it is — every test must pass without timing out, and one ending in assertions must satisfy those too — and its recent history as a colour on the left edge — green for a clean window, aqua when it passes now but has failed recently, and a distinct colour per failure kind (result check, crash, timeout, cycling, slow). What do the colours mean? opens the legend. The history comes from <name>.audit.json, which the harness appends to on every run, wherever the run was started.

A directory lists its tests newest first, by the run file’s modification date. The harness touches a test whose result has changed since its last run, a pass that was failing included, as well as every failure. So what has just moved is at the top. A test whose result changed within its last 5 runs is highlighted: a yellow ring and a changed tag, and its hover says what it was and what it is now.

Three rules watch the library, and they are proposals, never moves:

  • a test in current/ whose last 10 recorded runs all passed on this node’s Spiral is proposed for regression/;
  • a test in regression/ whose last run failed is proposed for current/;
  • a stale test is proposed for retired/, wherever it sits.

A test is stale when Spiral refuses the test’s own text — Json: add:<queue> not understood, or the <<HERE>> marker on a statement it cannot parse. Nothing has been learnt about the thing under test: the test is written in a language the optimiser has moved past, usually because an entity or an attribute it names has since been retired.

The harness reports it as its own result, Stale, and the laboratory treats it as one:

  • the card’s edge takes the stale colour, so it is never mistaken for a finding;
  • the lifecycle proposes retirement, not repair;
  • the minimiser refuses it — there is no fault in it to isolate.

That last point matters when a library has been carried over from an older tuning system. A stale test describes a way of working that is being retired along with the system that generated it, and the honest thing to do with it is to let it go.

Some tests never finish. Spiral gets within a few steps of a plan, then repeats the same output records many times a second, assigning a stop to a resource and taking it off again, and the plan is never optimised. The perf line on Spiral’s stderr gives it away. Its steps to do (todo — i + u + s) fall to a small number and stay there, never reaching 0, at one gear, while the output keeps streaming. An optimisation that is getting somewhere empties todo and climbs the gears. A test waiting out its own long pause for writes no output at all.

The harness watches for exactly that. When steps to do have sat above 0 and below 10, at one gear below 10, for 10 seconds while Spiral writes at least 10 records a second, it ends the run and reports its own result:

regression/invalidrepairtime2 FAIL Cycle: 2-record cycle on project:234370546 resource:1049403759 from +34s for 10s — gear 0, todo 2 never 0, 512 records/s

That run used to occupy its whole 450-second budget and report Timed out. Left without a budget, it flapped for 23 minutes before Spiral broke out by itself. Now it ends 45 seconds in.

  • The report lands beside the run file as <name>.cycle.json. It records when the stall began, the perf line it sat at, the output rate, and the records that were repeating, with every number masked except the ids and references, so the flap reads plainly. It also names the projects and resources those records write to.
  • A test that times out while cycling is reported as Cycle … (at the timeout). A cycle that began just before the alarm is still named for what it is.
  • The card takes the cycling colour (indigo, next to the timeout’s purple, because it is that kind of failure), and the suite tally counts Cycle: separately.
  • SPIRAL_CYCLE_SECONDS sets the window (default 10). 0 turns the watcher off.
  • The watcher is spiralCycle.js, run by the harness with node. Without node, or without the file beside the harness, the harness behaves exactly as before. In the laboratory it is always there.

To look back at a run that has already finished, ask the same question of its stream. It reports every cycle it finds, or none, and whether one was still going at the end:

Terminal window
node tests/spiralCycle.js scan tests/regression/invalidrepairtime2.err.json --tail 0 # pulled beside the harness
# ended at @1773528331 after 1401s: 2-record cycle on resource:1049403759 project:234370546 from +36s for 1399s — gear 0, todo 2 never 0, 517 records/s

--tail is how many megabytes of the end of the file to read (16 by default; 0 reads all of it).

The same detector runs on a live dev or tuning node, and on any node with a Spiral. When the optimiser stalls like this, an amber CYCLING appears beside the Spiral icon. Hover it for what is repeating, on what, and since when. The primary’s card in Mission Control says the same. It is reported, never acted on. A Restart Spiral clears it, as does Spiral breaking out by itself.

After a batch run, and whenever you press Review moves, the proposals open in a dialog: one row per test with the reason, a destination list with the recommended stage starred, a place for a note, and two buttons — Move or Ignore. Ignoring leaves the test where it is and stays quiet until that test’s history changes. Every move is recorded with its reason in the library’s journal, so the shape of the library is always explained.

If you would rather the laboratory kept the directories itself, tick lab manages directories: the recommended moves are then applied after each batch run. It is off by default, because a client’s own categorisation is theirs.

Press Open on a card (or double-click it). Every test opens parked at its first tag, and the dispatch panel’s header gains the transport:

ButtonWhat it does
⏪rewind. Reloads the test from the beginning on an empty database.
👣 / ⏸paused. Standing on a command the button is a step, and pressing it releases that command. Once released it becomes pause, because the step is now running towards the next tag or assertion.
▶playing. Every tagged command is released as it arrives, until you pause or the test ends.
⏩fast forward. Press it, type a tag, Enter: every tagged command below that tag is released without stopping. An assertion still parks — running to a tag never runs through the thing being asserted.
●recording. Playback stops and what you do next becomes part of the session.
✓check. Arms one click on a stop, to write down what should hold.
💾save. Writes the session, launch to now, as a test.

The three states are exclusive: pressing one turns the other two off, and none of them is released by pressing it again — to leave a state you choose another, so the transport is never in no state at all. The line beside them carries one thing at a time: the command you are standing on while stepping, Spiral’s own tag x of y progress while playing or fast-forwarding, and ● recording while recording. When the test has played out an END sign replaces the position: rewind to run it again.

A paused Spiral does not stop you. A released command queues on its stdin and is processed when the pause resolves, so stepping works throughout — it is simply a longer step. The Spiral icon beside the transport is what says the optimiser is pausing, and hovering it gives the time it is paused until.

Because the application is never read-only here, you can open editors, change values and probe between steps. That is the point: bring the replay to the moment you care about — the project whose allocation looked wrong, the surge that triggered outsourcing — and examine the world before letting the next command land.

Press ● and do the work: raise a job, move a resource, force an allocation, change a window. Every command the application sends Spiral joins the session, bracketed by comments that say when recording began and ended, so the saved test shows exactly where you took over. Press ⏸ or ▶ to close the recording — ● does not un-press, so you cannot leave the transport in no state at all.

This is how a test grows beyond what was recorded: replay to the interesting moment, then add the two or three commands that provoke the behaviour you want to capture.

Or start from here: Snapshot. ● builds a test from its beginning, so rewind before you record. A recording begun part-way through has no starting position, and Save refuses it. Snapshot, in the Dispatch header, takes the world as it stands now as the start of a new test and starts recording from it. The transport comes up with ● pressed, whether or not a test was open. Do the work, press ⏸ or ▶ to close the recording, and Save keeps it as development/snapshot-<date>-<time>. A test that was open stays where it was: the session now belongs to the snapshot, so Save never writes a partial test over it. Saving also ends a recording that is still running.

A snapshot is taken where Spiral is. Helix’s records change the moment a command is released, but a stop’s type, state and times come from Spiral’s answer. So a snapshot first waits for the answers Helix has already received. It is refused, naming the stops, if any are still unanswered, because Spiral has not read the commands that made them: it sits in a pause, or is cycling, before them. A test written then could never replay (All non [DONE] stops must have a type). Take the snapshot at a tag Spiral has reached, before releasing those commands. If the commands that follow it need to be in the new test, ⏩ through them while recording.

4. Conclude — write down what should hold

Section titled “4. Conclude — write down what should hold”

A test becomes a test when it ends with a conclusion: a settle, then one or more check or refute assertions (input object). You write them. Nothing is derived for you, because the point of the exercise is to state the requirement.

Open the stop you care about — double-click it, or press ✓ and click it once in the plan, which is the same thing in one click. In its editor’s Schedule card, on the row with Rebook and Hold, choose check or refute and tick the properties that matter: the resource, the desk, the state, the times, the type, the solution tier — only the ones Spiral actually gave the stop are offered. Press Add check. The assertion, and a settle before it, are recorded into the session.

The assert control needs Full or Developer mode, and a stop Spiral has scheduled. A dev or tuning node opens in Full for exactly this reason; on any other node, switch mode from the burger menu.

  • check — this must hold on replay.
  • refute — this must not.

Press 💾. You are asked for the name, and it defaults to the open test’s: keep it to overwrite that test, or type another to create a new one in development/, which then becomes the open test. What is written is the whole session, launch to now — the position, the commands played, the ones you recorded, and the conclusion — as one freestanding test that replays through the harness with no Helix at all.

6. Minimise — the smallest test that still shows it

Section titled “6. Minimise — the smallest test that still shows it”

A test captured from a live moment carries thousands of entities, of which a handful matter. Minimise reduces it to the least that still shows the fault, and proves every removal by replaying the candidate and asking Spiral.

There are two ways in, and one panel.

  • The dispatch header, beside Snapshot: Minimise is offered for the open test in two cases:

    • its audit, the record of its runs, says the last one failed clearly;
    • you have just watched it fail here: the open test’s run in your node has reached a clear failure, such as CYCLING or SPIRAL FAILED.

    The second case needs the test saved. With an unsaved recording, the file on disk is not what failed, so a minimisation would work on something else, and the button says to save first. Either way the panel’s first run confirms the failure in the test harness, and that run is what the minimiser holds. A run in your node is never written into the test’s audit, which stays the harness’s record.

  • The laboratory’s Sandbox card: the same rule. Its Minimise offers the displayed tests whose last run failed clearly. A test that passes, is stale or only timed out is not among them. A plain timeout says nothing about what went wrong, so there is no failure to hold. A cycling Spiral is reported as Cycle, which can be minimised.

It works on a copy, wherever the test lives. The panel copies the test into a work area under tests/.minimise/, and everything the minimisation runs happens there: the first run, its output, the candidates and the result. Nothing is written beside the test, so a regression test’s history is not touched until you decide what to keep.

First it has to know what the reduction must preserve, and that is simply how the test fails now. The panel runs the copy once, within the suite’s budget (SPIRAL_TEST_TIMEOUT), and says what it saw, in the harness’s own vocabulary:

  • Result with the exact Validation check … text;
  • Crash with the last input record;
  • Cycle with the projects and resources its repeating records write to;
  • Perf, Logic or Assertion.

A pass, a stale test or a plain timeout has nothing to hold, and the panel says so. A run the panel has seen before on the same text is remembered, so a second visit goes straight to the choice. The failure is the thing being isolated, so a reduction that cures it, or changes it into a different failure, is rejected and put back. There is nothing to author and nothing to edit.

Then press Minimise, holding that failure. The candidates run in the test harness, which is Spiral on its own, with no database or Helix behind it, several at once. They do not run in your node, so the open test stays open while it works. The minimiser:

  1. runs the reference twice — both must meet the objective, or there is nothing to anchor;
  2. pins every entity the conclusion, the objective and the played commands name, plus everything they reference (resources, desks, hubs);
  3. removes the rest of the starting position, collection by collection, coarsely first and then by binary search, keeping only what the objective survives without;
  4. does the same for the commands;
  5. confirms the result three times.

Then you keep the result, one of two ways:

  • Overwrite the test. The test is replaced by its minimised version and its history starts fresh. Its run record, its outputs and any crash core go, because they describe the test it was.
  • Save it as a new test. The name is relative to the test’s own directory, so irt2-min goes beside it and ../development/irt2-min goes on the bench. It cannot leave the tests tree or replace a test that exists.

The result carries its provenance on the first line: what it came from, how many lines became how many, how many runs it took, the Spiral it ran on and what it holds. A best-so-far result is kept after every successful reduction, so Interrupt — take the best so far leaves a valid, smaller test to keep, though not one confirmed three times. While it works, the panel shows the iterations so far, how many were successful reductions, and the stops taken out so far.

A timing target (Perf) is a measurement rather than a statement, so candidates run one at a time — racing them on one machine changes the very thing being measured — and even then a reduction can slip under the threshold by luck. The three-run confirmation is what catches that.

A cycle target is a state, not a stopwatch, so its candidates run in parallel as usual. Three rules keep it honest:

  • The projects and resources it names are pinned, because a world without the flapping stop’s project is a different world, not a smaller one.
  • A candidate holds the target if it cycles on at least one of them. The smaller world may flap the project against a different resource, and that is still the fault.
  • A cycle can begin late, so each candidate is given at least one and a half times the gate run’s own time.

The reduction is worth the wait. A full-size AA extract of 8008 position entities came down to 225 in 123 runs and five minutes; a small recorded test typically ends at one project, one resource and no zones in under a minute.

Promote moves a test along the lifecycle and records why. Reaching regression/ passes the reliability gate — the last 10 recorded runs all passing on this node’s Spiral — and the dialog shows that window before you commit to it. A human decision can force past the gate; the force is written into the journal with the reason.

challenge/ is the exception: a test there leaves only to retired/ or current/, by hand.

A test run and a minimisation both replay tests outside your node, in the sandbox: Spiral on its own, several at once. A minimisation’s first run, the one that finds out how the test fails, is its first stage. A node runs one sandbox activity at a time. There is no queue and no jumping in: the activity has to be stopped, or finish, before another can start. While the sandbox is busy, nothing offers to start another. Instead you see what is running and how to stop it.

You see it in three places, and only while it is busy:

  • In the laboratory, the Sandbox card. It is always there, with the same shape, and its head says what the sandbox is doing: available, or the activity.
    • Available, it offers its two options:

      • Run: Standard, Full or Visible, with the Spiral to run them on.
      • Minimise: a choice among the tests displayed whose last run failed clearly. It is not offered when none has. A minimised result waiting to be kept is noted here too, with Look in.

      The last run’s outcome, its failures and the harness’s tally stay beneath the options.

    • Busy, the progress takes the card over:

      • A test run shows its bar, its counts and its failures so far, with Stop. A stopped run keeps the results it had recorded. Any test whose result differs from its previous run is listed first, under Changed, as from → to, passes included, and a failure that changed carries a was … tag. The same list heads the last run’s outcome once the run has finished.
      • A minimisation shows its first run, or the stage, iterations, successful reductions and the stops taken out so far (stops 12940 → 4120 (−68%)). It has Look in, which opens the Minimise panel, and Interrupt — take the best so far, which ends it at once and leaves its best result so far to keep.

      Stopping it returns the card to available, so you can choose what to run next. Run on the test cards is unavailable meanwhile.

  • On the dispatch screen, beside the tuning controls. Press it to look in.
  • On the landing page, inside the Spiral Laboratory tile.

The activity belongs to the node, not to a page. Leaving the dispatch screen or the laboratory changes nothing, and it is there when you come back. Stopping the node stops it.

This is what the library is for. A new Spiral release, or a changed domain, must pass all of regression/.

  • Three run scopes, as three exclusive buttons. Standard is current/, regression/ and new/ — the stages a release is judged on. Full is every stage, the bench and the retired included. Visible is exactly what the console is listing, which is how you re-run a handful without composing anything. The running scope stays depressed until it finishes; stopping is a separate action and asks first, because the results already recorded still count.
  • The filter is a search, and it goes through the directories. Type a word and the laboratory looks at every test at or below the directory you are standing in, matching the path or the comment. The cards are this directory’s matches; each stage below it reports a count instead of its contents, so stby at the top of the library draws no test cards — there are no tests at the top — but shows that it is 74 tests in regression/ and 3 in challenge/ before you enter either. Step into one and you have its matches as cards. Visible runs the whole set, wherever it lives, which the node works out for itself. The stage cards are never filtered away, so a search can never strand you. And the filter is yours: the laboratory never clears it, it holds as you step through the directories, and it is still there when you come back.
  • Output, on a card, shows Spiral’s stderr from the last run — where a crash names what it was doing, and where the version banner sits. It is read-only and rewritten by every run: the run file is the thing you edit, through Edit. These files are large (the median in a mature library is around 190 KB), so what you get is the head and the tail with the middle elided and named, and the whole file’s path given — the banner is at the top and the failure at the bottom, which is what it is opened for.
  • Candidate binaries. Point a run at a Spiral build that is not yet the one Helix runs: put the builds in a directory, pass it as HELIX_CANDIDATES_DIR_HOST to helixctl … dev or tuning, and pick one from the header. The laboratory probes each for its version. A candidate’s run never changes the engine the node itself executes, and never proposes a move — the gate is about the Spiral you actually run.
  • History. Every completed run records the Spiral version and commit, the core version, the harness revision, the executable, the seconds taken and a checksum of the domain file it ran against. History lists every version the library has seen and compares two of them: new failures, fixes, unchanged, and the verdict — accepted, or N regressions. A domain change shows on the same axis, because the domain is part of the record.

9. Review the costs — Full / Developer mode and auto-allocate

Section titled “9. Review the costs — Full / Developer mode and auto-allocate”

Switch to Full or Developer mode from the top-bar settings menu (☰ → Mode: Full / Mode: Developer). Normal mode is the operator view; the tuning detail only shows in these.

Auto-allocate asks Spiral to re-place a stop freely and — crucially — to explain itself: right-click a stop row (or Ctrl+right-click its map marker) and choose Auto Allocate. Helix sends a force coerce with rid: 0 (recalculate the resource; did: 0 for desk assignment) and the tagged reply comes back as a Spiral reply dialog:

  • Warnings are translated through the domain’s helix.warning templates — the operator-facing reasons a placement was refused or penalised.
  • simpleSolns (Full/Developer only) is Spiral’s raw solution set — the candidate resources it considered with their solution data, as an expandable JSON tree. This is the primary cost-review surface: compare the candidates Spiral weighed and what separated them.

Per stop, the Schedule card shows what the current solution cost: dep / arv / fin, travel (tvl/dst), the solnTier the solution landed in, cbt (Full/Developer), and Spiral’s info messages. Developer mode additionally shows each tree node’s stop id.

10. Inspect the runtime domain in the JSON editor

Section titled “10. Inspect the runtime domain in the JSON editor”

Open the JSON Editor (☰ → JSON Editor). The entity buttons include the Define set — Stop Parameters, Resource Parameters, Customer Parameters, Contract Parameters, Topography — and Set Parameters, each seeded from the live reference record of the running system. Entries appear as defined, each with its clone still in place; double-click a stop or resource on the map to see the parameters it resolves to. The Dispatch Stream Viewer below it dumps the whole live in-memory database.

Use it to confirm what the running system was actually given, to follow a clone chain (they can surprise), and to copy the current stopParams block as the starting point for your tuned version.

The Define verb does not apply live. The editor lets you modify the JSON, but an interactive define post is rejected by the CRUD (define not available in crud mode): the domain reaches a running Spiral only through file replay or a restart. Validation therefore happens by replay — see below.

Click any key in the JSON Editor or the Tune panel, and the editor’s footer shows where it sits (stopParams › JOB › helix › icon) with a ? button. ? opens this site’s reference entry for that attribute in a separate documentation window, which you can move and size beside Helix. While that window is open it follows each key you select, so you can walk a block and read as you go.

If the key is a name you chose (a stop type, a template, a duty code, a skill domain), you land on the definition of the object that holds it. An attribute the reference does not describe yet lands on its containing definition too.

Every Helix link to this site (the user guide, the ? buttons, the tutorials) opens in that same window.

The Tune panel — edit the domain file in place

Section titled “The Tune panel — edit the domain file in place”

In Full or Developer mode on a solo system, the auto-allocate Spiral reply dialog offers a Tune… button (admin login required). It opens the client domain file itself — comments and all — as a navigable tree, positioned at the current stop’s stopParams entry:

  • Follow the clone chain: a clone reference is a link — clicking it jumps to the referenced definition and pushes your position onto a stack (the breadcrumb shows the chain, e.g. booked-2 ▸ appt-2 ▸ immediate); ⬅ Back returns. This makes it quick to find where an inherited cost actually comes from. The jump stays inside the entry’s own catalog — a key is only unique there, and a file may well carry a DEFAULT in resourceParams and another in contractParams — preferring the entry’s own array when a catalog is split over several define blocks; a clone on a row inside an entry (a contract’s duty rows) resolves to a sibling row.
  • Edit values in place: click any value, type, Enter. Edits are applied surgically to the file text, so comments and layout are preserved — only the value you changed is touched.
  • Look it up: click a key, then ? (see above). The panel’s header ? opens this section.
  • Save and Reload saves the file and restarts Spiral: the domain is re-read from disk and the app’s parameters refresh, so the next auto-allocate runs under the tuned values. (A one-off .bak of the original is kept alongside.)

The panel also opens from the entity editors in Full/Developer mode, from a Definition row of chips: the stop editor’s names the stop type and the customer (Tune RSS, Tune RSS-AA PERSONAL), and the resource editor’s names the resource type and its contract (Tune MEDIUM_VAN, Tune PBC); the project editor’s footer has Tune <type>. One tuning window exists at a time.

Structural changes (adding keys or entries) belong in your editor or the JSON editor’s define flow — the Tune panel is for the tuning loop: costs, times, factors.

Two equivalent validation routes; both end by replaying the same test and comparing decisions:

  1. Domain-file route (recommended): edit <CLIENT>.domain.json — a delay.cost, a noSoln.costStop, an option t. In dev and tuning the domain is read live from the mounted repo, so the change applies on the next load: reopen the same test and step to the same moment.
  2. Test route: for a self-contained experiment, copy the tuned define block into a copy of the test, ahead of the recorded commands — replay applies defines in-stream, so the recorded traffic runs under the modified domain without touching the real file.

Compare before and after: the same auto-allocate on the same stop — did the candidate ranking change as intended? Did solnTier improve? Did the warnings disappear? Stepping makes the comparison precise, because you can halt at the identical tag in both runs.

Then make it permanent:

  • Dev: already done — the dev loop reads <CLIENT>.domain.json live from your repo (dev loop); commit it.
  • Production: rebuild and ship the client bundle — helixctl release <version> --as bundle packs the extension and the domain config, then helixctl import + start <type> <artefact> on the node (build & deploy).

Finally, run the library against the changed domain. The domain checksum is part of every result, so “passed on this domain” is on record.

12. Probe a vehicle’s track — Spiral and the travel calculator, fix by fix

Section titled “12. Probe a vehicle’s track — Spiral and the travel calculator, fix by fix”

A resource heading to a stop reports its position every few seconds, and each fix makes Spiral re-estimate the drive. With the OSRM travel calculator on, that estimate has been seen to lengthen or wander in ways the vehicle’s progress does not explain, and a stop deassigned as a result. The probe makes that visible: the same fixes reach Spiral in the same order at the same clock, with a settle after each, on the bare engine with the calculator on and then off, and a table shows what the plan did after every fix.

The intent it tests. The calculator should correct future plans and have little effect on plans in progress — a stop that is deployed, heading, committed or arrived. So the figure that matters is the number of rid changes on in-progress stops with the calculator on, less the same number with it off. It should be zero.

Make the run file. The best source is a Spiral record stream — a node’s spiral.log, or any .err.json the harness wrote — because it carries the clock on every command Spiral was given ({"@":T,"IN":…}, with debug 4 or more). A run file or a crud2spiral.log is untimed; it works, with --interval to space the fixes.

Terminal window
node helixCore/avlRun.js make --from spiral.log --resource 1854604495 --window 1705496000,1705498400 \
--out tests/development/avl-1854604495.run.json
node helixCore/avlRun.js synth --base tests/regression/avlupdate1.run.json \
--route 2089458407:148423236,2089464644:148422887 --every 60 --speed 50 --out tests/development/avl-two.run.json

make replays the world to the window as it happened, then per fix of the resource (or --resource R1,R2, or all) writes a set time, the fix tagged from 1001, and a settle. synth takes a base world’s starting position and moves two patrols along straight lines to their stops at one cadence, fixes interleaved — the shape in which two similar journeys can be swapped between patrols — with --offset (metres to the right of travel: a wrong carriageway) and --jitter (seeded noise) to perturb them. Both land in tests/development/, the bench.

Run it both ways.

Terminal window
helixCore/bin/avlProbe tests/development/avl-two.run.json # calculator from $SPIRAL_TRAVEL_CALCULATOR, then '{}'
helixCore/bin/avlProbe tests/development/avl-two.run.json --threads 1 --map my-precision-0.0001.map.json
helixCore/bin/avlProbe tests/development/avl-two.run.json --exe ~/release/spiral.l64 --exe-b helixCore/spiral.l64

The driver copies the experiment to <stem>.osrm and <stem>.flat, runs each through the core’s own testSpiral, and records every byte Spiral exchanged with the calculator in <stem>.osrm.travel.in.json and .travel.out.json through a shim it installs for the run. --threads 1 makes the order of requests and replies certain; --map is a replacement map, for example one with precision 0.0001; --exe-b runs a second engine for a four-way comparison.

Read the table. One row per fix. imm is the drive time Spiral recomputed synchronously on the fix, on its internal estimate — the coarse value; opt is the first settle, set the last; c→f is set − imm, the delta between coarse and fine. Δtvl is the change in the settled drive since the previous fix of the same stop, exp is what elapsed time alone would give (minus the seconds between fixes), and dev their difference. eng.t is the calculator’s own answer for that fix to that stop, and req lists every request the calculator received from that fix, as stop:seconds — which is how you see Spiral pricing a candidate assignment: a patrol’s fix asking for the drive to the other patrol’s stop, with the fine value, while the patrol already heading there stays on the coarse one. changes lists every in-progress stop whose rid changed in the step, stop:from→to, whoever ends up with it.

flagmeaning
DEASSIGNan in-progress stop changed rid in this step; changes says which
SWAPtwo in-progress stops exchanged rids in one step
SWAP_BACKa change that reverts one from the last three steps — the wobble, two changes a stop
TVL_UPthe settled drive rose by more than a minute while the stop stayed with its patrol
DEVthe drive moved by more than two minutes against what elapsed time explains
SHAPEdrive time rose while distance fell, or distance rose while the fix moved toward the stop
SPIKEa TVL_UP that was back within a minute of the earlier value two fixes later
IMM_NE_SETcoarse and fine differ by more than a minute
OSCthe plan went un-optimised and optimised again more than once within the step
CELLa TVL_UP or DEV that coincided with the fix crossing a precision cell
ARV_GT_LATESTthe settled arrival is after the stop’s latest

The summary under each table gives the in-progress rid changes, the largest and mean rise in drive time, the spread of dev, and the number of calculator requests; the comparison puts the on and off rows side by side and prints the one figure the intent is judged by. Everything is also written beside the run file as .avl.json and .avl.csv.

What it is not. Evidence, not a mitigation: nothing here changes what Spiral does with a fix. A HEAD stop’s first value is the input’s own — Spiral initially calculates non-deployed stops only — so the first row of a stop has no previous to compare with and raises no flag.

Coarse against fine over a country. The same tools measure the two estimators without any fixes at all: pairs builds a world of N resources each standing at an origin with one stop at a destination, locked to it, and the probe runs it on and off; the off run’s tvl on each stop is Spiral’s coarse drive and the tee capture holds OSRM’s reply for the same two points, which the analyser divides by the calculator’s speedFactor to give the fine value Spiral would put on an unstarted stop. Spiral asks the calculator for about a hundred drives in a run, so a thousand pairs are ten batches, pooled with --out.

Terminal window
for k in $(seq 1 10); do node helixCore/avlRun.js pairs --base tests/regression/avlupdate1.run.json \
--resource 2089458407 --project 148423236 --positions stops.txt --n 100 --per-cell 4 --seed $k \
--earliest 0 --settle 0 --out tests/development/avl-pairs-b$k.run.json; done
for f in tests/development/avl-pairs-b*.run.json; do helixCore/bin/avlProbe "$f"; done
node helixCore/avlAnalyse.js pairs --out tests/development/avl-pairs-uk tests/development/avl-pairs-b*.run.json

--positions is one [lat,lng] a line — a client’s own stop positions, say — and --per-cell caps the destinations per 1° cell so one city does not fill the sample. The table gives the ratio coarse ÷ fine by distance band, by latitude and by longitude, with the ratio to OSRM’s raw duration beside it: a systemic drift is a ratio that moves with one of them; a flat ratio away from 1.0 is the speedFactor. On the AA’s map, Spiral’s coarse drive matched OSRM’s raw duration to within 1% over 50 km and ran over it under 25 km, so at a speedFactor of 0.75 every fine drive over 50 km came back a third longer than the coarse one.

testSpiral is a helixCore component, shipped with the core and present in the image at /app/helixCore/testSpiral — one place to maintain it. A client repo does not commit a copy. Pull the harness the image actually carries:

Terminal window
./helix/runtime/helixctl harness # → tests/testSpiral + testSpiralRun + spiralRunDir + spiralCycle.js + the lifecycle directories

Both land in tests/, which every client .gitignore ignores, so they can never be committed and can never drift from the core. That copy is yours, for running tests from a shell. It is the harness of the core you are pinned to, so re-run helixctl harness after moving to a new one.

The laboratory never uses it. Run, the minimiser and its first run always use the harness of the core the node is running, against that node’s own Spiral. The laboratory’s results, its cycle detection and how a stopped run is recorded therefore always match the code reading them, whatever was pulled into tests/.

Terminal window
cd <repo>/tests && ./testSpiral # the standard suite: current/ regression/ new/
cd <repo>/tests && ./testSpiral -c # current/ only (-r for regression/)
./testSpiral tests/current/t1.run.json # one test, named by path, from anywhere in the repo

testSpiral is a bash script. Beside each Spiral it starts the optional cycle watcher, node spiralCycle.js watch (see Cycling tests), and stops it when the run ends. With no node on the machine, nothing changes.

A run that is stopped part-way, by the sandbox’s Stop, a batch’s Stop or Ctrl-C, is closed in the audit as stopped. It has no verdict, so it counts as neither a pass nor a failure, and its card stops showing it as running.

A run file may be named however you have it: a bare name (current/t1), the same with its .run.json suffix, a relative path, or an absolute one. The run’s .err.json, .out.json and .audit.json always land beside the run file, whichever directory you ran the harness from.

Spiral runs in a scratch directory beside the harness (tests/.work/) that links to your client’s config directory — the one holding <CLIENT>.domain.json and <CLIENT>.map.json. A run file names them bare and Spiral resolves a bare include against its own working directory, so the links make it resolve to the real file — under its real name, so a stale include still fails as Stale — while nothing Spiral writes lands in the repo: a crash core is moved beside the harness as tests/core.<test> and the result line names it. The harness finds the config directory in this order, and refuses to run if it finds none, rather than starting Spiral with no domain:

$SPIRAL_TEST_CONFIG_DIRyou name it
$HELIX_CLIENT_PATHinside a container, the unpacked client
<repo>/helix/customthe ordinary client repo — nothing to set up
<repo>older trees whose domain sits at the top
a client tar$HELIX_CLIENT_TAR, or a *_helixConfig.tar beside the harness — unpacked once into a cache

That last row is what lets a machine with no working tree at all run the library: a host that has only the installed artefact and a core image can still replay the whole corpus.

$SPIRAL_EXE if it names an executable; else a native spiral.l64 beside the harness or in the repo’s release/; else a spiralOnly drop-in, which runs the pinned core in a container. So a client repo with no local Spiral build needs nothing extra — the tests run against exactly the engine the client is pinned to. With none of the three it says so instead of running nothing.

The drop-in runs within a few seconds of native on a host whose image store is a kernel overlay. On a rootless host whose store is fuse-overlayfs every file operation inside the container is a round trip to a user-space process, and one thing Spiral does continuously is exactly that: with TZ unset, glibc stats /etc/localtime on every time conversion, thousands a second, which halved the drop-in’s throughput on one host. Spiral 2.5.4 pins TZ at start, so the check becomes a string compare and the file is read once; helixctl check shows the storage driver it found. A drop-in that runs at half native speed on a fuse-overlayfs host is on a Spiral older than 2.5.4.

Point both Helix and the harness at the same engine when you are tuning against a new Spiral build:

Terminal window
export SPIRAL_EXE=~/Honeywick/Helix/helixCore/spiral.l64

Helix probes the binary it actually runs, so the About dialog reports the truth either way; and the laboratory’s own batch runs always use the engine this node runs, unless you pick a candidate.