Browse packs / Research / kausalflow

Homeostat

Process-plant time series with full ground truth from a declarative scenario: validation with actionable errors, exact discretization, reproducible random streams, a scriptable CLI.

kausalflow/homeostatversion 1Apache-2.0 56 decisions242 checks

Build this pack

Paste this into Claude Code, Codex or Cursor, with your language. Your agent runs every step below on its own and stops when the pack's checks pass.

Build https://symbola.sh/packs/kausalflow/homeostat/ in Python
The steps, to run yourself or to see what your agent does
  1. Install the Symbola CLI (Python 3.11+): uv tool install symbola (or pipx install symbola). Not pip install symbolon: that PyPI name is another project.

  2. Download the pack: symbola pull kausalflow/homeostat --dest spec.

  3. Read spec/AGENTS.md and follow it. Build only from spec/: don't look for the original product's source, the registry repository (it holds holdout tests) or other builds. Packs released before the rename say symbolon <command>: run symbola <command> instead.

  4. Build each component in its own folder and run its checks as you go, fixing what fails:

    symbola impl-init impl/cli-contract --contract spec/components/cli-contract/versions/1
    symbola test spec/components/cli-contract/versions/1 impl/cli-contract
    

    In Python, define run(input) in impl.py: it takes and returns a dict, and raises ValueError for input the contract rejects. Pin third-party packages with == in requirements.lock. In another language, set language = "command" and command = [...] in symbolon-impl.toml; the program reads one JSON input per line on stdin and writes {"output": ...} per line.

  5. When every component passes, run symbola keygen once, then for each component symbola attest spec/components/cli-contract/versions/1 impl/cli-contract -o attestations/cli-contract.json. On macOS, attest needs Docker running and accepts Python, Node or a static Linux binary (for Go, GOOS=linux); symbola test runs the same checks without it.

You are done when attest reports PASSED for every component. Write open questions and the readings you chose in NOTES.md. Every command is described at https://kausalflow.github.io/symbola/cli/.

Components:

  • cli-contract: spec/components/cli-contract/versions/1
  • run-series: spec/components/run-series/versions/1
  • scenario-issues: spec/components/scenario-issues/versions/1
  • scenario-truth: spec/components/scenario-truth/versions/1

Every command: Symbola CLI docs. Agents can read these steps as plain text in llms.txt.

Evidence

Who has built it

Test results that ship with the pack, one column per build. No evidence yet.

No evidence yet. Build this pack and run symbola attest, then open a pull request on the registry with the attestation to list it here.

Obligations

What a build must do

Each obligation is one decision a builder could get wrong. Decided is required, default may change in a fork with a note, and open is deliberately left to the builder.

issues

  1. validate reports all the problems of the stage that finds any, not only the first: several missing parameters, several bad expectations and targets, and several schema problems come back together. issues.report-every-problem-of-a-stage · 5 checks
  2. Problems are found in six stages (text, schema and vocabulary, building, events, wiring, checks on the compiled plant) and a stage with any problem hides the problems of the later ones; the message to the user is to fix them and validate again. issues.stages · 10 checks · default
  3. An issue is located at the offending key as a path: dotted keys with bracketed list positions (`exogenous[0].source.tau`, `interventions[2]`, `expect[1].signal`); an unknown or misspelt key is located at that key, and a missing key at the key that is missing. Problems of a custom operator found while building are located `custom[i]: <id>` or `custom[i]: <id>.<param or port>`; wiring problems found after building are located `<id>.<port>` or `<id>`. issues.location-at-the-offending-key · 17 checks
  4. A misspelt key, operator, port, signal, loop or template gets the closest known name as a hint in the fix (SequenceMatcher ratio of at least 0.6, best first); a name with nothing close gets none. issues.did-you-mean · 13 checks
  5. Every problem carries a suggested fix except an E_SCHEMA error for a value of the wrong type or form; issues are structured (code, location, reason, fix) and the wording of reason and fix is not part of the contract. issues.fix-offered · 12 checks
  6. The contract reports issues sorted by (location, code) and without repeats, so that outputs compare as plain data; the order of the original's own list is incidental. issues.sorted-report · 2 checks · default
  7. A scenario whose operators are wired acyclically through existing signals and whose numbers are within range is valid, and has no warnings; a minimal scenario needs only a duration. issues.valid-scenarios-pass · 12 checks
  8. A scenario given as YAML text, JSON text or an object is the same scenario (JSON is YAML); text that is not YAML is E_YAML, and text or a value that is not a mapping is E_SCHEMA at `scenario`. issues.text-forms · 7 checks
  9. Any malformed scenario is reported as issues and never raises an exception: ok is exactly 'no errors', errors have E_ codes, warnings W_ codes, and an invalid scenario has no warnings. issues.malformed-input-is-reported · 4 checks
  10. Durations are numbers of seconds or strings of number+unit parts (ms, s, sec, min, h, hr, d, w; spaces and a leading sign allowed); dt and duration must be positive, duration a whole multiple of dt, output.every a whole multiple of dt, and noise_keys one per lane, each reported as E_SCHEMA at its key. issues.durations · 9 checks
  11. A scenario has exactly the stated keys at each level; an exogenous entry has exactly one of source and from, an intervention a value or a fault, a stat/shift expectation at least one bound, and an expectation a known kind. issues.schema-shape · 8 checks
  12. Templates, faults, measurements, degradations and maintenance need a library (E_SCHEMA at `library` when absent); an uninstalled library is E_UNKNOWN_LIBRARY, a pin that is not the installed major version E_LIBRARY_VERSION, an unknown template E_UNKNOWN_TEMPLATE at `units[i].template`. issues.library-and-templates · 6 checks
  13. Operator parameters are checked when the scenario is built: an unknown parameter name, a missing required parameter (each one), a value that is not a number (a boolean, or a string that does not read as a number; the string 2 counts as the number 2) or out of the declared range (tau and std at least 0, Kc at least 0) is E_BAD_PARAM; zero is inside a range that starts at 0. issues.operator-parameters · 7 checks
  14. Every signal has exactly one writer: an operator id that another operator or an exogenous source already writes is E_DUPLICATE_OPERATOR together with E_DUPLICATE_WRITER; a signal an input reads that nobody writes is E_UNKNOWN_SIGNAL. issues.single-writer · 5 checks
  15. Inputs must be connected (E_MISSING_INPUT, per input, a sum having one input per weight), only existing ports may be wired (E_UNKNOWN_PORT), an operator period must be a whole multiple of dt (E_BAD_PERIOD), an unknown operator is E_UNKNOWN_OP, and `from` in an exogenous entry must name a port of a unit (E_UNKNOWN_TARGET at `exogenous[i].target`). issues.wiring · 8 checks
  16. A cycle in which every operator has direct feedthrough from the previous one is rejected as E_ALGEBRAIC_LOOP with the cycle as its location (ids in the direction of the signal flow joined by ' -> ', closed by repeating the first); an operator without feedthrough (a lag, an integrator) on the cycle makes it valid. The contract rotates the path to start at the smallest id; where the original starts is incidental. issues.algebraic-loops · 5 checks
  17. A controller's process-variable input must be the end of an observation chain, so that controllers read measured values and never true ones: otherwise E_BAD_FEEDBACK_INPUT at `<id>.pv`. issues.feedback-reads-measured · 1 check
  18. An event or a regime value may change only a schedule or a float parameter path `<operator>.<param>`: the regulated variable of a loop is E_TARGET_REGULATED (change its reference), a controller output E_TARGET_CONTROLLER_OUTPUT (switch to manual), anything else another operator writes E_TARGET_NOT_SCHEDULABLE, and a name that is neither a signal nor a parameter E_UNKNOWN_TARGET. issues.event-targets · 11 checks
  19. An intervention is rejected with E_BAD_EVENT when its time is negative, a ramp has no positive duration, a lane is outside the run's lanes, the mode of a loop is changed other than by a step, or a mode name is not MAN, AUTO or CAS. issues.event-fields · 7 checks
  20. The plan's start and its transitions must name regimes that exist (E_UNKNOWN_REGIME at `plan.start` or `plan.production[i].to`); the values of the starting regime are located at `regimes.<start>.<key>`, those of a transition's regime at the transition. issues.plan-references · 5 checks
  21. An expectation naming a signal, loop, lane, window, transition or id that cannot exist is E_BAD_EXPECT, located at the field, so that it is reported before a long run; names of signals include the unmeasured and true ones. issues.expectations-name-real-things · 7 checks
  22. A valid scenario still gets warnings for parts that have no effect: W_UNUSED_SIGNAL (an exogenous source nothing reads, at `exogenous[i].target`), W_EVENT_AFTER_END (an event due at or after the end of the run, once per entry, at `interventions[i]` or `plan.production[i]`) and W_UNUSED_REGIME (a regime that is neither the start nor a transition's target, at `regimes.<name>`). issues.warnings · 11 checks
  23. The wording of reason and fix texts, and the order in which the original lists issues, are open: builders choose them. issues.message-wording · open

truth

  1. The signal table, loops, roles, causal graph, event log, promoted parameters and timeline follow from the scenario alone: they do not depend on the seed, the noise, the initial state or any simulated value. truth.independent-of-randomness · 2 checks
  2. There is one signal per operator output, named by the operator's id (sorted), with its writer, the writer's operator name, the kind of operator (source, dynamic, feedback, observation), the declared unit ('' if none, null for a promoted parameter) and whether the historian records it: measured signals and every loop's reference, mode and output, but not manual outputs or remote references. truth.signal-table · 5 checks
  3. A signal is `measured` when an observation operator writes it and no observation operator reads it, `internal` when one does, and `true` otherwise. truth.levels · 3 checks
  4. Roles are inferred from the graph and are relations to a loop: for each pid, the regulated variable (found by walking back through the observation chain behind its process-variable input), the measured signal, reference (setpoint and remote setpoint), mode, manual output and manipulated variable; a signal may hold several roles, also of several loops. A signal with no loop role has a global one: parameter, exogenous, measured, observation stage or free state. truth.roles-from-structure · 5 checks
  5. Each pid is a loop named by its id, with its controller, output, measured, regulated, reference, remote reference, mode and manual signals and its output limits (defaults 0 and 100). truth.loops · 4 checks
  6. The causal graph has every signal as a node and an edge from each connected input (and each promoted parameter signal) of an operator to each of its outputs; an edge is instantaneous when the input has direct feedthrough to the output in the same step, and promoted parameters always do. truth.causal-graph · 8 checks
  7. A scenario that is invalid has no ground truth: asking for it is an error (here ValueError), not an empty or partial answer. truth.invalid-scenarios-have-no-truth · 2 checks
  8. A parameter becomes a signal, a node of the causal graph and a `parameter` role only when an event (an intervention or a regime value) targets it; untargeted parameters stay constants. A parameter that cannot change during a run is not a valid target. truth.parameters-promoted-on-demand · 6 checks
  9. An event is logged once per lane it applies to, at the first step at or after its time (applied_at); events due at or after the end of the run are not logged. truth.event-log-times · 4 checks
  10. value_before is what the target holds when the event is applied (an unfinished ramp counts at its current value) and value_after what it holds when the event's profile is complete; set, shift and scale act on the value held, and a mode name is 0, 1 or 2 for MAN, AUTO, CAS in any case. truth.event-log-values · 7 checks
  11. Events applied at the same step go in the order of their time, and for the same time: interventions as written, then production transitions in time order; the log is listed by (applied_at, id, lane), and this order decides value_before when events share a target. truth.event-order · 3 checks · default
  12. An intervention's event id is its `id`, else `ev<position>`; a transition's events are `<transition id, else transition<position>>.<j>` with j the key's position in the regime; labels are `planned` and `transition:P->R`, origins '' and `plan.production[i]`. truth.event-ids · 4 checks · default
  13. The starting regime gives initial values and logs no event; a production transition logs one event per key of its regime (a ramp when it has a duration and the value is a number, else a step); the timeline lists regime and transition intervals in seconds, ordered by start, covering the run: rows starting at or after the end are dropped and ends clipped. truth.plan-and-timeline · 5 checks

series

  1. Each step applies the events due, computes outputs from this step's inputs, records, then updates states: a schedule shows an event in the row of the step that applies it, an operator without direct feedthrough (first_order, lag, integrator) reacts one row later, and a parameter change acts from its step on the state reached. series.step-order · 6 checks
  2. A run starts settled, noise off, at the steady state of the initial values (first_order y = K u, lag y = u), so an event at time 0 changes the inputs, not the starting state. series.settled-start · 3 checks
  3. Dynamics are discretized exactly for an input held over each step, so the response at the grid times does not depend on dt: first_order and lag follow y = y_inf + (y_0 - y_inf) exp(-(t - t0)/tau) for a step at t0, whatever dt divides t0. series.exact-discretization · 3 checks
  4. A schedule holds its value until an event: set, shift and scale act on the value held (events at the same time in written order), a ramp is linear from the step that applies it to the target over its duration, and `lanes` limits an event to some lanes. series.schedule-events · 6 checks
  5. Rows are recorded every output.every (default dt) from 0 below the duration, and thinning the output does not change the simulation. series.recording · 3 checks
  6. An integrator adds k dt u to its state each step and clips the state to [lo, hi], from `initial`. series.integrator · 2 checks · default
  7. Asking for a signal the plant does not have, or running an invalid scenario, is an error. series.unknown-signal-or-invalid · 2 checks
  8. The same scenario and seed give the same series every time; a different seed gives different noise. series.reproducible · 1 check
  9. Random streams are keyed by the operator's id: adding, removing or reordering other operators, or events elsewhere in the plant, never changes an operator's noise; two operators have unrelated noise. series.keyed-streams · 2 checks
  10. Lanes carry noise keys (default: lane k has key k); a lane's noise depends only on the seed, the key and the operator, so lanes with equal keys share noise even when an event differs between them, and the number of lanes does not matter. series.noise-keys · 2 checks
  11. An OU source is parametrized by its stationary mean and standard deviation and correlation time, discretized exactly: the same spread and autocorrelation exp(-dt/tau) at any dt; it starts from its stationary distribution. series.ou-parametrization · 1 check
  12. An AR source is parametrized by its stationary standard deviation (not the innovation's) and mean, with coefficients per base step; for one coefficient the lag-1 autocorrelation is that coefficient. series.ar-parametrization · 1 check

cli

  1. Exit 0 on success (also with warnings, and for no command at all); 1 when the scenario or the request is invalid; 2 for a usage error; 3 only when `diagnose` ran and an expectation did not hold (an invalid scenario is still 1). cli.exit-codes · 24 checks
  2. validate always prints its report on standard output; every other command prints problems as issue lines on standard error with nothing on standard output, or with --json as {ok: false, errors} on standard output with standard error empty. cli.problem-channels · 11 checks · default
  3. validate prints `OK <name>` or `INVALID <name>: <n> problem(s)` as its first line in text mode; with --json one object {ok, file, errors, warnings, summary} whose summary is null when invalid and otherwise describes the scenario (durations formatted as 1d3h46min40s, steps, lanes, seed, library, units, loops, signal and operator counts, event counts by kind, promoted parameters, expectations). cli.validate-report · 11 checks
  4. The top-level keys of the JSON the other commands print are fixed: run (ok, rows, span, recorded_every, lanes, start, observed, unreported_signals, all_signals, n_events, events, warnings, reproducibility, elapsed_s, and files with --out), explain, diagnose (ok, checked, passed, failed, errors, checks, warnings, reproducibility; ok iff nothing failed), diff. cli.json-shapes · 8 checks · default
  5. A scenario argument of `-` reads standard input, and the name reported is `<stdin>`. cli.stdin · 4 checks
  6. `run --out DIR` writes truth.csv, measured.csv, observed.csv and meta.json (the ground truth) into DIR, with one CSV per lane (`<view>.lane<k>.csv`) when there are several lanes. cli.run-output-files · 4 checks
  7. `convert` rewrites the scenario as a JSON object or as YAML text without validating it; a missing file is still E_FILE with exit 1. cli.convert · 4 checks · default
  8. The layout of text output other than the first line of validate (run tables, explain, diagnose, diff) is open. cli.text-layout · open
Assumptions

What it takes for granted

Facts about the world the pack relies on, and how to notice when one stops holding.

K-process-library-is-installed

The domain library `process` (version 1) is installed next to the core whenever a scenario names it, so `library: process@1` loads without a network or an install step.

Watch for: `homeostat validate` of any scenario with `library: process` reports E_UNKNOWN_LIBRARY if it is not.

K-same-numpy-version

Two runs give identical numbers only on the same Homeostat and NumPy versions: the random generator (PCG64 seeded through SeedSequence) and the system maths library are taken to behave the same for those versions. Across versions, platforms or CPUs the numbers may differ in the last bits, or entirely.

Watch for: Compare run.meta['reproducibility'] of the two runs before comparing their values.

K-scenario-sizes-are-sane

A scenario asks for a run that fits in memory and finishes in reasonable time: validation does not bound duration/dt, lanes, or the number of operators, and a run keeps every recorded row of every signal in memory. Times are float seconds, so durations and steps are assumed small enough that 1e-9 dt tolerances still separate whole steps (years at millisecond steps are not assumed to work).

Watch for: The 'steps' count in `homeostat validate --json` summary (duration / dt x lanes) before running.

K-time-is-simulated

All times in a scenario are simulated seconds from the start of the run; nothing reads the wall clock, a time zone or a calendar. `output.start` only relabels the index, as a naive timestamp, and plays no part in the dynamics.

Watch for: none

K-trusted-plugins-and-snapshots

Everything the engine loads is trusted: domain libraries found through the 'homeostat.libraries' entry point group run arbitrary code when a scenario names them, and Engine.restore() unpickles its input, so it runs arbitrary code for a snapshot from anyone else.

Watch for: none

K-yaml-reads-the-scenario-as-written

Scenario authors know YAML's reading of plain scalars: unquoted on/off/yes/no/y/n are booleans and 1e3 without a dot may be a string. The loader does not warn about it (this is why the expectation field is called `signal` and not `on`), and an id such as NO is read as a boolean.

Watch for: none

Brief

homeostat

Homeostat generates time series of the kind a process plant's historian records (flows, levels, temperatures, controller outputs, lab values) together with the full truth behind them, from abstract data-generating processes rather than physics. A user, usually an AI assistant, writes a declarative scenario (YAML or JSON): a plant built from templates or from core operators, noise sources, planned changes and faults at given times, a production and maintenance plan. Homeostat validates the scenario, runs it, and hands back the values at three levels (true, measured, observed) and the ground truth: the signal table, the control loops and the roles of their signals, the causal graph, the log of every event and the timeline of the plan.

This pack covers the part a rebuild must get exactly right for those users to trust it:

  • what a scenario says about the plant (scenario-truth): signals, loops, roles, causal graph, events and timeline, which follow from the scenario alone and never from the random draw;
  • what is wrong with a scenario (scenario-issues): the code, location and suggested fix of every problem, found before anything runs, and the warnings for a valid scenario that has parts with no effect;
  • how a run unfolds (run-series): the order inside a step, exact (dt-independent) discretization, ramps, and reproducible random streams keyed by operator;
  • how the command line reports it (cli-contract): exit codes, where problems are printed, and the shape of --json output.

The scenario language is specified for the core vocabulary (the operators in the scenario-issues table, exogenous sources, interventions, regimes and plan.production). The domain library process (templates, ISA tags, faults, degradations, maintenance, sensor presets) is not specified by this pack.

Non-goals

  • Physics. Homeostat does not solve first-principles models; its plants are composed of abstract operators, and a rebuild must not add mechanistic simulation.
  • Bit-identical numbers across NumPy versions, platforms or CPUs, or between a rebuild and the original. Only the properties stated (ground truth, exact discretization, stationary statistics, reproducibility of one implementation with itself) are required.
  • Speed. The original has an optional compiled step; no rebuild is required to be fast, only correct and reproducible.
  • A graphical interface, a server, or live operation: the scenario file in, the run out.

Priorities

When goals conflict, earlier ones win:

  1. A scenario that validates must be something the engine can run, and one that does not must be rejected with the right code, a location at the offending key, and a fix a person or an assistant can act on.
  2. Ground truth is exact and complete: every event, role, edge and interval in the scenario is recorded, and none of it depends on the seed.
  3. Runs are reproducible, and random streams are keyed by operator and noise key, so changing one part of a scenario never changes the noise of another.
  4. Dynamics are discretized exactly: changing dt never changes the physical response.
  5. The command line is scriptable: stable exit codes and JSON shapes.
  6. Readability of messages and of text output.

Who resolves gaps

kausalflow, as the namespace holder. If the pack doesn't settle something, record the question and the reading you chose in NOTES.md next to your build; obligations marked open in obligations.toml are yours to choose.

Files

Everything in the pack

/ 4
assumptions 6
components/cli-contract/versions/1 4
components/cli-contract/versions/1/mutants 8
components/cli-contract/versions/1/schema 2
components/run-series/versions/1 4
components/run-series/versions/1/mutants 5
components/run-series/versions/1/schema 2
components/scenario-issues/versions/1 4
components/scenario-issues/versions/1/mutants 8
components/scenario-issues/versions/1/schema 2
components/scenario-truth/versions/1 4
components/scenario-truth/versions/1/mutants 9
components/scenario-truth/versions/1/schema 2