Skip to main content

Arbiter

A detection engine that reports what it did not check.

Most checkers answer one question: what is wrong? When they return nothing, you cannot tell whether they looked and found nothing, or never looked at all. Those two results are printed identically, and only one of them is good news.

Arbiter separates them. Every evaluation that declines to run is recorded — with a machine-readable reason — alongside the findings, and every pass reports how many evaluations it attempted. A clean result means these invariants were tested and held, and it is distinguishable from nothing was testable.

The envelope

A detection pass returns findings, declines, and a denominator -- and a fourth leg for what the model never declared at all:

findings              what was found
not_checked           what was NOT evaluated, and why
checked.invariants    how many (axiom, entity, indicator) evaluations were tried
questions             what the model is MISSING -- in words, typed and located

questions is the leg for what you never declared. The distinction it carries is the one not_checked cannot: a decline says the model asked for something this run did not have, and a question says the model never asked. Both are required members of every envelope; the four legs above are what every verb satisfies identically.

This leg shipped for several releases named only in schema/envelope.schema.json and in one line of COMPATIBILITY.md about ordering -- in no prose here at all, and this section listed three legs where the schema required five. Nothing was broken and every test passed; the surface was simply unfindable by anyone learning the engine from its documents, and it was registered as a capability to be built by someone who had read them. tests/test_the_envelope_legs_are_documented.py now derives the required members from the schema and fails if this section stops naming one.

gaps is the verb that fills it. check and model_describe return it empty; traverse fills it only when a start node is absent from the topology. Each entry carries the question in words, a gap_type from a closed set of six -- missing_node, missing_edge, missing_property, missing_threshold, missing_dynamics, missing_declaration -- the location it is about, a priority, and the context_path that reached it. Entries are deduplicated on (gap_type, location).

Empty and absent are different here too, and meta.source is where you tell them apart. A session with no topology answers source: unavailable and no questions, which is not the same statement as a model with nothing missing.

priority is a distance, not a severity, and both populations it ranks are on one scale. A question carries its gap_type's weight decayed by 1 / (1 + hops) from the walk's start, so the same gap ranks lower the further the engine had to go to find it -- measured, one dangling edge scored 1.0 as its own start node and 0.333 two hops out. Structural gaps, which the builder computes rather than a walk finding, are scored at hop zero and so carry their type's full weight: 0.8 for a missing edge, 0.6 for a missing property. The ORDER of questions is not part of the compatibility contract, so sort it yourself if you need it fixed.

Every envelope carries meta.schema_version. It names the WIRE SHAPE, not the package version, and it moves only when a reader that worked stops working — adding a key does not move it. COMPATIBILITY.md says what a patch release may change and what waits, and schema/envelope.schema.json is the shape machine-readable. The reason all three exist: the describe payload's nesting moved once between releases with no signal at all, and a consumer who had written against the earlier layout got None back from a lookup — which reads as this engine does not support that rather than this moved.

not_checked entries carry a reason from a closed vocabulary of fourteen — not_applicable, insufficient_samples, missing_property, no_current_value, missing_config, missing_entity_type, missing_role, no_rule_for_role, no_threshold, partially_checked, precondition_unmet, undefined_for_values, wrong_indicator_type, checker_error — so a decline is data, not a log line.

Three of them will account for most of what you see. insufficient_samples reports both the count it had and the count it needed, so it tells you how much longer to collect. missing_role means an axiom needed to know what KIND of quantity an indicator is — a count, a percentage, a ratio, a latency — and the model never said. Declare role: on the indicator. The engine does not guess it from the indicator's NAME, so this decline is about the declaration and never about the spelling.

no_rule_for_role is its opposite and the two are worth telling apart before you act on either. Here the model DID say, correctly, and the axiom has no rule for that kind of quantity — CONSISTENCY has rules for counts, percentages and ratios, and none for a latency. Nothing is owed and nothing needs declaring; the pair simply does not evaluate. Until 0.1.14 both answers arrived as missing_role, which sent an author who had already declared a role looking for one.

no_current_value is the newest and the reason it exists is worth stating. A threshold axiom reads Entity.properties; a temporal axiom reads observation history. Feed only the second and the value is genuinely present and genuinely unreadable by the checker that wants it — and until 2026-08-16 that said missing_property, which told a caller holding sixty observations of a property that there was no value for it. It now names the count and which store it is in. That was reported from outside, and the vocabulary was the thing at fault: a closed set missing a member does not raise, it reclassifies the case as the nearest member and reports it with confidence.

Why the denominator matters. Findings and declines do not sum to the total: an evaluation that ran and found nothing appears in neither. Without checked.invariants, the statement checked N invariants has no honest value of N — and an envelope reporting a fabricated denominator is the exact failure the envelope exists to prevent.

All eight axiom checkers emit declines (43 call sites). This is not a property of one checker that the others aspire to.

And one decline comes before any checker runs. A numeric property reading NaN or an infinity is not a measurement: NaN compares false against everything, so no threshold is breached and no rule is broken, and an infinity compares fine and reports a precise wrong answer. Every axiom declines that cell as undefined_for_values rather than judging it.

The eight axioms

Declared per-indicator in a domain model, not in code:

Axiom Asks
BOUNDEDNESS does this stay inside its bounds?
STABILITY does it settle, or oscillate?
HOMEOSTASIS does it return to baseline after disturbance?
MONOTONICITY does it move only in the permitted direction?
CONSERVATION does what goes in come out?
CONNECTIVITY is the topology intact?
CONSISTENCY is this value possible, and does it match its declared twin?
RESPONSIVENESS does it react within its deadline?

BOUNDEDNESS takes a floor as well as a ceiling. warning: and critical: are ceilings; lower_warning: and lower_critical: are floors, and declaring both pairs on one indicator gives you a band. A fan that must not stop declares lower_critical: 1000 and gets back speed_rpm is below critical threshold with the reading it was given — no negated property, no translation layer. Whether you should declare a floor at all is a different question, and MODELING.md answers it: transcribe one when a datasheet or a contract gives you the number, and reach for HOMEOSTASIS when nobody has.

CONSISTENCY answers two separate questions. By default it range-checks a value against what its role: permits — a count is not negative, a percentage is within 0-100 — and that rule reads one indicator and nothing else. Declaring consistency: {agrees_with: [other_property]} adds the second: two readings the model says are redundant have to match, within a tolerance: (relative) or tolerance_absolute:. Redundancy is declared because nothing about two numbers reveals that they measure the same thing. A named peer the entity does not carry is reported in not_checked rather than skipped.

Redundancy is a claim about the system, not an inference from naming — and populating agrees_with from a naming convention is the way this check fails. Two channels of one part are the tempting pair and often the wrong one: a temperature sensor exposing Name and Name1 may be reporting its own die and an external diode, which differ by tens of degrees on a healthy board. Pair them and the engine will faithfully report disagreement between two readings that were never supposed to agree — a false finding on every working machine, produced by a configuration rather than by a fault. The same is true of consecutive record numbers, matching suffixes, and anything else derivable from a string: if a rule can generate the pairs, it does not know the pairs. They have to come from someone who knows the system, and they should be versioned and pinned like any other declaration. This is not hypothetical — it was reported by an integrator who caught the suffix rule in review before it shipped.

- name: cpuUsageNanoCores
  type: NUMERIC
  axioms: [STABILITY, BOUNDEDNESS, HOMEOSTASIS]
  warning: 3
  critical: 10
  window: 1h

An empty axioms: [] is meaningful — the values flow into observation history without a per-cycle check. Silence is a declaration here, not an omission.

role: — two axioms need to know what kind of quantity they are reading

RESPONSIVENESS and CONSISTENCY carry rules about a quantity, not about an entity: a deadline applies to a latency, and 0 <= x <= 100 applies to a percentage. Declare which:

- name: setpoint_error_pct
  role: latency            # latency | count | percentage | ratio
  axioms: [RESPONSIVENESS]
  warning: 5
  critical: 12

Leave it out and the axiom declines missing_role, whatever the indicator is called. The engine used to infer one from the name — response/latency for the first, count/percent/pct/ ratio for the second — and that was a guess about English: two indicators identical in every declared respect were treated differently because of their spelling, and no surface said which had happened. Worse for a reader, the ABSENCE of the decline was not evidence a role had been supplied. It was evidence about the name. An indicator called pulldown_error_c could declare RESPONSIVENESS, be accepted, be listed by model_describe, and never once evaluate.

You do not have to run a cycle to find that out. model_describe reports unreachable_declarations — every declared (indicator, axiom) pair that cannot fire under any input, each with the remedy — and the loader logs the same list. An empty list is the target.

expect_variation: — a reading that stopped moving is not a reading

A sensor frozen at its last value passes every threshold it is under, and STABILITY measures oscillation, so a flat line scores as the most stable input there is. Until 2026-08-16 a dead sensor and a live one produced byte-identical envelopes. Declare that a quantity should move:

- name: speed_rpm
  axioms: [STABILITY, BOUNDEDNESS]
  expect_variation: true
  window: 30m

Then a series that never changes across the window is a finding, frozen_series:<indicator>, naming the value and the count.

STABILITY in that axioms: list is load-bearing, and the field is inert without it. STABILITY is the axiom that reads the series, so an indicator declaring expect_variation: true alongside only threshold axioms gets no finding and no decline — which is indistinguishable from a healthy sensor, and is the thing this field exists to end. Copying the block above works; editing an indicator you already have is where it bites. model_describe names it: unread_fields lists every declared field whose consuming axiom is absent, with the remedy. That check exists because this was reported from outside the day after the field shipped.

It also names a key the engine does not read at all, which is the case that catches a typo. Each row carries a reason: axiom_not_declared for the above, and unknown_key for a key that is not in the schema — with a did_you_mean where one is close. expect_variaton: true is accepted by YAML, read by nothing, and would otherwise leave exactly the silence the field was added to end.

And it names properties you send that nothing reads. unread_properties lists numeric entity properties for which no indicator is declared. It reports what arrived; it does not judge the value, because deciding what a number means needs a rule and this engine takes rules from your model rather than from the property's name. Earlier versions did guess: a key spelled *_count or *_pct was range-checked whether or not you asked. That is gone — declare role: and the axiom to get those checks — and this report is how you find the properties that declaration is missing from.

check carries that report too, and the reason is the guess it replaced. Removing the guess withdrew a check: a value that used to raise impossible_value off its property's spelling now raises nothing. A withdrawn check that says nothing is indistinguishable from one that passed, which is the single thing COMPATIBILITY.md forbids a patch release from doing — so the population rides on check as well, and a run whose faults moved into undeclared properties reads as a run with something unlooked-at rather than as a clean one. It is a report, not a decline: a decline record names an indicator and an axiom, and a property nobody declared has neither.

Leave it out and nothing is reported, and that silence is the design rather than a gap. Whether a constant series is a fault is a question about your domain and not about the number: a CPU temperature that never moves is broken, and a replica count, a nominal setpoint and a switched-off pump are all correctly flat. The engine cannot tell those apart and does not try. You can.

The axioms reading the value are not suppressed when this fires. A sensor frozen above its critical threshold still raises that alarm; you get both, and can judge the threshold verdict knowing the input behind it is dead.

Quickstart

pip install arbiter-engine          # requires numpy and pyyaml, and nothing else

python3 -c "
from importlib.resources import files
from arbiter_engine.api import EngineSession, model_describe

s = EngineSession()
s.load_model(files('arbiter_engine').joinpath('examples/water_tank.yaml').read_text())
print(model_describe(s).to_dict()['checked'])"

The example is read out of the INSTALLED PACKAGE rather than off a relative path, and that detail is load-bearing rather than stylistic. A wheel ships only what lives under the package directory, so the copy at examples/ in this repository reaches the source distribution and not the wheel. This block used to open examples/water_tank.yaml directly: correct from a clone, and FileNotFoundError for anyone who installed the package instead — a failure that could not appear until the install line above stopped saying git clone. Both copies are here, written from one source: examples/ for reading, the packaged one for running.

That prints {'invariants': 0, 'entities': 3, 'declared_invariants': 11} — three entities, eleven declared invariants, and zero evaluated, because no observations have been supplied yet. The zero is the point: it is reported rather than left for you to infer from an empty finding list.

Everything above is on the supported surface. Until 2026-08-11 this example imported load_domain from a deep module path — which works, and which this same README calls importable and unsupported three sections down. The first thing a reader runs should not be the one thing the document tells them not to depend on.

examples/water_tank.yaml is a deliberately synthetic two-tank water system that declares all eight axioms in one file, so it doubles as the schema reference. It is not one of the curated domain models, which are not published — and neither kubernetes_node.yaml, battery_pack.yaml, factory_line.yaml nor margin_book.yaml is one of those either, though every one of them ships here — and reading it is the fastest way to learn the shape. kubernetes_node.yaml and battery_pack.yaml are the same kind of thing on domains where a floor and a band carry the weight. factory_line.yaml is a manufacturing cell, and it is the one whose vocabulary shares no nouns with the engine's own. margin_book.yaml is the only one that declares work the engine has not been given yet: an outside forecaster is expected to supply a prediction, the engine keeps the books on whether it did, and the forecaster is then judged by the same eight axioms as everything else.

Dependencies are two, and that was measured rather than assumed. numpy and pyyaml are required. scipy and rdflib are extras ([confidence], [rdf]) because they are reached only through two deep modules that the public API never touches — so the naive reading of the import list says four, and the measurement says two.

The public API

15 names. Everything else in the package is importable and unsupported — reaching for a deeper path is legitimate and unpromised, and those paths may move without a major version.

from arbiter_engine import (
    TopologyTraverser,          # the kernel: problem-solving as graph traversal
    UnifiedAxiomReasoner,       # evaluates axioms, produces the envelope
    DomainModel,                # your YAML, loaded
    InMemoryObservationHistory, # a concrete history, so it runs without a store
    SqliteObservationHistory,   # the same contract, durable, for replaying history
    SqlitePredictionLedger,     # the durable ledger, so calibration outlives the process
    SessionCalendar,            # when the modelled world is open
    CalendarHistory,            # windows measured in open time rather than wall clock
    Entity, Problem, RelationshipGraph, Observation, Axiom, Severity,
    api,                        # the tool surface — see below
)

Fourteen of those are types and the kernel; the fifteenth is a module, and the split is deliberate. arbiter_engine.api is the tool surface: eleven verbs over a session, each returning the envelope above. Five answer for the eight axioms; four answer for a DISCIPLINE — work of a different kind, with its own denominator and its own vocabulary of refusals, reported in a payload beside the legs rather than inside them.

from importlib.resources import files
from arbiter_engine.api import (
    EngineSession, model_describe, check, traverse, gaps, attest,
    project, discover, entail, infer,
)

session = EngineSession()
session.load_model(files("arbiter_engine").joinpath("examples/water_tank.yaml").read_text())
model_describe(session)   # what is declared: entity types, indicators, axioms
check(session)            # evaluate the declared invariants over supplied observations
gaps(session)             # what the model says should exist and nothing has been observed

Read out of the installed package again, for the reason given above. This block opened the relative path until 0.1.6, which is the failure that paragraph describes, forty lines further down the same document — so 0.1.5 ships a project page whose second code block raises FileNotFoundError for anyone who installed it. Found by running the README that shipped inside the wheel, from a directory with no repository in it, rather than a rewritten version of it.

Three kinds of input, one feeder each. A session takes the current value of a property, the series behind it, and the edges between entities — and every axiom reads one or both of the first two, except CONNECTIVITY, which reads only the third.

session.add_entity("pump1", "Pump", properties={"speed_rpm": 2900})
session.add_observations("pump1", "speed_rpm", [2900, 2905, 2890, ...])
session.add_relationship("pump1", "feeds", "header")   # source, relation, target

The first two are easy to conflate and worth separating deliberately: threshold checks read the entity's current properties, and the temporal axioms read observation history. Supplying one and not the other is the commonest way to get a clean result over a value that is plainly out of range — the threshold checker never saw it, because the current value lives on the entity.

Omit the third and CONNECTIVITY will report a missing relationship, which is correct: a model that declares a pump must feed a tank is asserting something, and an absent edge falsifies it. That finding is not a complaint that you forgot to load edges — the engine cannot tell those apart, so it reports what the model asserted and lets you decide which it was.

They are a supported contract, and they are listed here as one name rather than six because they serve a different caller: an agent invoking tools, not a library user composing objects. check is not a peer of Entity, and flattening them into one namespace would say it was. The module is the promise; its membership is documented here and does not change inside a minor version.

Those verbs, and the four session-setup calls beside them — load_model, add_entity, add_relationship, add_observations — are exposed over MCP by arbiter_engine.mcp.server — fifteen tools, a thin transport over exactly these functions, needing the optional mcp extra. That module is a deep path: importable, and not one of the supported names above. The sentence here read the same five … not part of the eleven through 0.1.16, naming a verb count that was never five and a name count two releases stale.

api also carries the forecast feeders — ingest_forecasts, feed_model_figures, model_figures and as_of. They are feeders rather than verbs, like the three session-setup calls: they put something in, and check is what reports on it. They live on api from 0.1.18 for a plain reason — an indicator declaring forecast: {expected: true} is waiting for a prediction this engine will never make, so a bridge is the only thing that can supply one, and until 0.1.18 there was no supported spelling for the call. The first bridge built on this engine reached through arbiter_engine.forecast and arbiter_engine.clock because those were the only paths that existed. Both still work and neither is promised. BRIDGES.md documents the record shape, source=, the raced vocabulary and the ordering.

Is this a world model?

Not in the sense the term now carries, and the difference is the whole design. A world model is learned — a network fitted to trajectories or video, which returns a next state for any input, including inputs it has never seen and without being able to say which those were. This engine's dynamics are declared. A coupling is a sentence with a number and its provenance, and a block missing any of from, to, gain, source is refused by name rather than completed with a default.

One thing IS completed with a default, and saying so is the point. A coupling's TIME COURSE is optional: an edge with no temporal: block, or one short of a key, keeps this engine's own 60 s dead time and 60 s time constant. That default is not small — on the shipped pump-and-tank model, leaving out time_constant_s alone moves the first projected level from 61.01 to 69.99 and reports a tank as settled that is halfway there. So it is reported rather than applied quietly: the key nobody wrote and the number standing in for it come back as a question, and every envelope computed across that edge carries time_course_not_declared in its assumptions.

The overlap is narrow and mostly one word. rollout here means: apply declared transitions and declared action effects on a private clone of the session, evaluate all eight axioms over every imagined state, prefix those findings imagined_ so a simulated breach can never read as a live one, and dispatch nothing, ever. It does not mean sampling a learned latent forward under a policy.

The engine does fit numbers, and stops short of adopting them. gain: estimate declares the coupling and withholds the number; the fit is reported under model_describe with its n, its r_squared and an interval, and it projects nothing until an author writes a number down. discover proposes edges and never adopts one. The rule is the same in both: measuring and proposing are the engine's, declaring is the author's.

Who counts as a producer, and what this engine owes one, is STANCE.md. That page answers the question this section raises and does not answer: what a submission is, what source= withdraws, and why the engine sets its own records aside from its own audit. It is deliberately narrow and does not restate the argument below.

Where a learned model fits — as the producer, with this engine keeping the books. Declare forecast: on an indicator and feed predictions through ingest_forecasts. The eight axioms then run over the forecast itself; a forecast that never arrives is a finding, because expected_from made it an obligation; an unknown or stale producer is declined by name; and every producer is graded against a parameter-free random-walk baseline fitted only on what it could have seen when it issued. examples/margin_book.yaml is that shape. The division of labour is the point: a world model predicts, and this audits.

Including when the thing being audited is this engine. A rollout that files its predictions is scored the way a producer's forecast is: pinball loss, a CRPS approximation and interval coverage, reported under calibration.own_projections beside the coverage a well-declared spread should have. That matters because the simpler figure is gameable. Asking only whether a later reading landed inside the band your own gain_sigma: drew rewards drawing a wider one — measured on one tank, a spread ten times too wide scored a perfect hit rate where an honest one scored 0.95, and the CRPS ranked them 3.19 against 0.99 the other way round. A hit rate is therefore reported against the rate it should be, and the scores that grow with the width of an interval are reported beside it.

What is not here, and why

The engine is open. The knowledge and the operations are not.

  • Domain models. The engine reads them; the curated packs are not published. The mechanism is the contribution — the models are the accumulated work. Everything in examples/ is a synthetic teaching model, deliberately not among them.
  • The operator half. Clinic, planning, the Kubernetes executor, the introspection layer. These are welded to a running deployment and are not v0.1.
  • A prediction ledger wired in by default. It is still not wired for you — but as of this release it is one argument away rather than one deep import away. EngineSession(ledger=...) takes a ledger, SqlitePredictionLedger is a supported name, and passing nothing gets exactly what it always got: an in-memory ledger that dies with the process. Until this release the only route was assigning session.ledger from an unsupported path, which left calibration — the one figure grading this engine against a parameter-free random walk — outside the surface while the envelope reported it. Two outside reviews wrote the same sentence about that. grade_matured scores a record when its horizon has passed and the record is still in the session's ledger, so did it beat a random walk is answerable only by a ledger outliving the horizon: a resident session, or a file you asked for. A one-shot command that loads a model, ingests a feed and exits will report calibration with every rate null, every time — correctly, because nothing in that run matured. Feeding forecasts that have ALREADY matured is not the way around it either: on a model declaring max_age: the forecasts leg declines them stale_forecast, since the leg is asking whether the producer is current and cannot tell late from here to be scored. Written down because the number's absence otherwise reads as a defect in the feed.
  • Two facts about grading that cost an outside reviewer two attempts each, and cost the author three, so they are here rather than only in a docstring. A record is not gradeable the instant its horizon passes: grade_matured returns nothing until now reaches predicted_at + horizon_s + grace_s, and grace_s defaults to 60 seconds. Ask earlier and you get silence, not a verdict. And an observation outside the window grades nothing: a reading taken after horizon_s + grace_s leaves the record ungradeable rather than falsified, because not looking at the right instant is not evidence about what was there. Neither is a defect; both are discovered by trial unless somebody writes them down.
  • The RDF layer, as something the engine reads. rdf is a supported extra and health_meta_ontology.ttl ships inside the package, so the graph the loader's namespaces point at is one you can open. Nothing in the engine loads it. OntologyLoader.load_meta_ontology takes a path from its caller, UnifiedAxiomReasoner calls it only when one is passed, and no supported verb passes one. Wiring it in was measured and declined, not deferred: the graph declares the vocabulary a DOMAIN ontology would use and carries no entity class of its own, so a loader with it parsed resolves the same indicators as one without — loading it by default would cost a parse at startup and change no answer. It is an interchange format kept for one platform; the extra exists so a caller who wants to read the graph themselves gets a declared dependency rather than a guess, and the file ships so that the comment naming it can be checked from where you are standing.
  • Two lazy imports reach outside the cut, and they behave differently. One root-cause wiring module and an LLM client are imported lazily and are not shipped, so the package still imports cleanly. The root-cause wiring degrades to a no-op — its callsite is guarded and the feature it reports is optional telemetry. The LLM path raises, with a message saying so; it is reachable only through NLTraversalTranslator, which is not part of the supported surface, and the deterministic translate() needs no client. Both measured by running them, not read off the imports.

Status

v0.2. 95 Python files, 93 modules importing on the declared dependencies alone, 15 supported names — counted in this repository, which is the package you are holding.

That basis is stated because it is easy to get wrong in a way nobody notices. The build adds one __init__.py per package level, so a count taken before the build is smaller than the package you are holding — and this line published the smaller figure until 2026-08-12, where any reader could falsify it with find . -name '*.py' | wc -l. A checkable false claim, in the Status section of a project whose subject is checkable claims. Count the artifact, never an earlier stage of it.

The import figure carries the same hazard one layer down, and it depends on what you have installed. Sweeping the package where scipy happens to be present imports 94; on the declared dependencies alone it is the 93 above, because propagation.lp_confidence is the one module that needs scipy and it is a deep path outside the supported surface. Count the artifact in the state the reader will have it, not in the state the person measuring happens to be standing in — this line quoted the with-scipy figure until 2026-08-12, which no reader installing normally could reproduce.

The supported-name count went stale in exactly the way this section warns about. It read three short for the release that added SqliteObservationHistory, SessionCalendar and CalendarHistory — three names listed eight paragraphs above, in the same document, under a heading that already had the right total. One number stated twice will drift, and the copy that drifts is the one no reader is looking at while they read the other. It is now derived from arbiter_engine.__all__ by a test rather than typed, which is the only version of this fix that stays fixed.

This paragraph then became the third copy. It quoted the heading's figure to make its point, so when the count moved to fifteen the erratum still said fourteen — and the test that derives the count found two different numbers in one document, which is the defect the paragraph is about, committed by the paragraph. The figures are described here now rather than quoted, on the same rule the changelog states for its own errata: a correction that reproduces the string it corrects is that string to a checker.

And the count is of SUBMODULES: the root package is not one of them. Walking arbiter_engine for what it contains gives 93; adding the package you imported to reach them gives 94. Both are honest and they are answers to different questions, so a reader who recounts and gets one more has not found a defect — they have used the other convention. Stated because someone did exactly that from outside, and a number published without its predicate can only be agreed with or disagreed with, never checked. Every figure in this paragraph moved by one when assumptions landed, and nothing went red for two rounds -- the guard that compares them to the artifact only runs when a built tree exists, so it is silent in every session that does not build one. A check conditioned on an artifact is not a check that runs.

Honest boundaries, stated because you would otherwise find them yourself:

  • The engine forecasts, and it ships no forecasting model of any domain. project produces projected values from a declared dynamics: block, and the forecasts leg scores predictions an outside model sends. What is deliberately absent is a model that knows which indicators matter here -- that is domain knowledge, and it belongs on your side of the line.
  • Eight worked examples ship, not a library of them. Modelling a real system is your work.
  • Stage I and Stage II of this project are archived, not running. Anything describing them as production is out of date.

How fast is it

Measured on this project's development machine, so treat the shape of the curve as the claim and the absolute numbers as an illustration:

entities evaluations check() per evaluation
10 60 12 ms 198 us
100 600 86 ms 143 us
1000 6000 831 ms 139 us

Four indicators per entity across five axioms, 40 observations per series, five runs per size, median reported. It is linear in evaluations, and the per-evaluation cost does not degrade with scale — that is the part worth knowing, and it is the part that does not depend on the machine.

Getting a model and its data in is excluded from that table, and it is three costs on two axes. Feeding entities scales with the entities:

entities feed per entity
10 15 ms 1535 us
100 146 ms 1463 us
1000 1546 ms 1546 us

Loading a model scales with the model, and the parse in front of it is the larger cost:

indicators invariants yaml.safe_load() load_model()
4 8 4 ms 0.20 ms
40 80 38 ms 1.23 ms
180 360 164 ms 4.77 ms
360 720 327 ms 11.61 ms

Three things follow, and the third is the one that saves anybody time. The feed is paid for whatever data is added, so a consumer re-feeding every cycle pays it every cycle. load_model() is flat in the entity count, so a session held across cycles pays it once — but it is not flat in the model, and quoting it from a four-indicator fixture is how it gets called negligible. And load_model() accepts a parsed mapping, YAML text or a path: at 180 indicators the YAML parse in front of it costs about thirty times the load, so a consumer paying a quarter-second to get a generated model in is mostly paying PyYAML. Cache the parsed mapping and hand it that rather than re-reading the file, and use yaml.CSafeLoader where libyaml is installed — same result, several times faster.

Re-derive it rather than trusting the tables. The two axes are separate arguments, because they are separate questions:

python3 -m arbiter_engine.scripts.benchmark_check --sizes 10,100,1000 --model-sizes 4,40,180,360 --repeat 5

Documentation

File Answers
MODELING.md how to write a domain model, and the rule that is easy to get wrong
BRIDGES.md how to write the program that feeds one, starting from the reasons this engine refuses to answer
STANCE.md who counts as a producer, what a submission is, and what this engine owes one
CHANGELOG.md what changed, and which version numbers do not exist
COMPATIBILITY.md what a patch release may change, and what waits
schema/envelope.schema.json the response shape, machine-readable

Eight worked models ship in examples/: water_tank.yaml declares all eight axioms and doubles as the schema reference, kubernetes_node.yaml is the smallest domain where a band matters, battery_pack.yaml is one where nearly every bound is a floor somebody published, factory_line.yaml is a manufacturing cell whose vocabulary shares no nouns with the other three, margin_book.yaml is the only one declaring work the engine has not been handed yet -- an outside forecaster is expected to supply a prediction, and is then judged by the same eight axioms -- and pump_tank_dynamics.yaml is the only one that declares DYNAMICS: a transition: with a steady-state gain, a temporal: block giving that coupling a delay and a time constant, an action_templates: block, and a planning: objective. It is what rollout, plan and the fitted gains under model_describe can be run against; until it shipped, the simulation surface had no specimen and a reader had to write one from the prose in MODELING.md. substation_feeder.yaml is the seventh and closes the same gap for infer: it is the only one declaring edge_direction: causal edges with noisy-OR strengths, so it is the only model the causal verb can be run against. Its header states one question answered three ways -- a feeder unobserved, observed, and intervened on with do -- because the last two put that feeder in the same state and the answers differ by a factor of thirteen, which is the whole reason infer is a verb rather than a filter over traverse.

pump_tank_planning.yaml is the eighth, and the only one that declares planning.max_depth: above

  1. plan has searched COMBINATIONS of actions since 0.2.3 -- the depth defaults to 1, so nothing searched unless a model asked, and no shipped model asked. This one is the same tank with a drain valve beside the pump: two independent levers on one level, where the best plan is a PAIR of settings and beats every single action, and where opening the drain too far costs more than leaving it shut. It is a sibling of pump_tank_dynamics.yaml rather than an extension of it, because that file declares one action template on purpose and a deeper search over one lever finds only refusals.

substation_feeder_surprises.yaml is beside it and is not a model: it is a worked SURPRISE CORPUS, the companion format the benchmark below reads. Four entries against the model above, chosen so that between them they produce every verdict the scorer can reach — one the declared model catches, one it provably cannot, one nobody was recording for, and one that was never confirmed.

Scoring this engine against things that actually happened

A test count is not a score. python3 -m arbiter_engine.scripts.surprise_benchmark replays a model over a store and asks, of each event a human confirmed afterwards, whether this engine said so at the time.

The figure comes in two senses and they do not travel apart. Of the sixteen observations from this project's own closed-loop alpha, SIXTEEN surprised the operator at first sight and NONE were anticipated by the design of the probes — which is favourable, and is a statement about foresight — and NONE were surfaced by the detector at the time, with three reached on a later replay — which is unfavourable, and is a statement about the detector. Both are 0 of 16. SurpriseScore therefore has no single rate to quote: it carries counts, and it names its denominator in a sentence.

Why the detector column reads as it does, which is a question about REACH rather than sensitivity. Most of those sixteen have no property whose reading could have revealed them: they are observations about the collection pipeline, about the fault instrument that was supposed to be exercising the system, or about a relation between two endpoints over time. A detector reading a domain model could not have surfaced them however sensitive it was, because their subject is not in the model. That is also why they cannot simply be written up as a corpus for the benchmark above — an entry needs a subject, a window and an axiom that could have fired, and inventing those for an observation that has none would score the invention.

A window with no data is a refusal, not a miss. An entry whose span holds no observation of its subject declines not_replayable and leaves the denominator, because scoring it zero would measure whether anyone was recording rather than whether this engine noticed. A predicate that declares nothing is refused outright — it would match the first finding of any kind on that entity, and credit the engine for seeing something else.

No number is published here yet, and that is the honest state. The one real corpus this project holds is an observation record of a platform that is not this package, and its store is not in this tree — so the benchmark's own not_replayable is the correct answer to asking it today. What ships is the instrument and a worked corpus, not a score.

Built on this engine: bmc-sensor-audit audits firmware sensor coverage, and factory-line-audit bridges a discrete-manufacturing line read over OPC UA — the one whose vocabulary shares no nouns with the others. Both call the engine directly. fleet-sensor-baseline is a step further out: it aggregates bmc-sensor-audit's output across a fleet and never calls the engine at all. All three are by this author rather than independent adopters, so take them as worked examples of the shape in BRIDGES.md and not as evidence anyone else has done it.

Evidence and technical write-ups live in evidence/ — architecture, deployment runbook, fault-scenario catalogue, and the observation logs from the closed-loop alpha, including the findings that went against us.

Licence

Apache License 2.0. See LICENSE and NOTICE.

TRADEMARK.md is separate and narrower: Apache Section 6 withholds any trademark grant, and that file says what use of the name is permitted. Arbiter is a project name, not a licence grant.

Contributing

See CONTRIBUTING.md. Adversarial findings are the most useful thing you can send: if the engine reports a clean pass over something it did not actually evaluate, that is the bug this project most wants to hear about.

Release files for arbiter-engine 0.2.6

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for arbiter-engine 0.2.6
File Size Uploaded
arbiter_engine-0.2.6.tar.gz 1.3 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for arbiter-engine 0.2.6
File Interpreter ABI Platform
arbiter_engine-0.2.6-py3-none-any.whl Python 3 none any Details

Total release size: 2.0 MB

Release files / arbiter_engine-0.2.6.tar.gz

Download URL arbiter_engine-0.2.6.tar.gz
Size 1.3 MB
Tags Source
SHA-256 checksum
How to use checksums
9db797b2d01a188b0df59106d5f66e2cc0ffc8468f066b7db07742f1a33ca4e2
BLAKE2b-256 checksum
How to use checksums
5cde815e96bd530687e14c5e0013a5f19ea66b5d4d2b372381089dcce9a84aef
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.10.12

Release files / arbiter_engine-0.2.6-py3-none-any.whl

Download URL arbiter_engine-0.2.6-py3-none-any.whl
Size 661.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e8e9e038f2f48ab9636376c370f4dc25afb49fb5c8f6dd43ef1c7c8ca19276da
BLAKE2b-256 checksum
How to use checksums
075fe123656fc59e13f3e357bfd8db11e6c0184d008d5586f1eb574d745184ac
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.10.12

Release history Release notifications | RSS feed

This release

0.2.6 This release

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.1.18

2 release files

0.1.17

2 release files

0.1.16

2 release files

0.1.15

2 release files

0.1.14

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page