Pydantic schemas bridging PureLMS (AGPL) and its simulation backends (MIT)
Project description
purelms-shared
Pydantic schemas that bridge PureLMS (AGPL-3.0-or-later) and the simulation backend containers (MIT, in purelms-interactive-tasks — formerly purelms-backends).
What's in here
purelms_shared.envelopes—SimulationInputEnvelopeandSimulationOutputEnvelope, the JSON contract for run input + output. Plus the helper types:InputFile,ResourceFile,OutputArtifact,Message,ExecutionContext.purelms_shared.callbacks—ProgressCallbackandCompleteCallback, the request bodies a backend POSTs to the worker during a run.purelms_shared.evidence—EvidenceManifestandEvidenceReference, the immutable record of a completed run that credential issuance reads later.purelms_shared.constants—RunStatus,OutputStatus,MessageLevel,BackendCallbackEvent,InputFileRoleenums, plus thepurelms.{input,output,evidence}.v1schema-version literals.
What's not in here
- No Django.
- No business logic.
- No dependencies beyond
pydantic.
If you find yourself reaching for any of those while writing code here, you've probably wandered into the LMS's purelms.simulations Django app or a specific backend's runner — not this package.
Why it's MIT
PureLMS is AGPL-3.0-or-later (matching Validibot's Community Edition). Simulation backends are MIT so educator-contributors aren't burdened with copyleft for the per-backend image they ship.
The only artifact that crosses the AGPL/MIT boundary is this schema package — so it's MIT too. Neither side imports the other's code; they share a data contract.
Versioning
Schema versions are dotted-package strings, not integers:
purelms.input.v1— input envelope shape v1purelms.output.v1— output envelope shape v1purelms.evidence.v1— evidence manifest shape v1
Additive changes (new optional field with a default) ship in v1. Incompatible changes bump to v2 with a new class living alongside the old one — running backends pin specific versions of this package, and we never break old envelopes.
"Additive within v1" is NOT backward compatible at the wire level. Pydantic's ConfigDict(extra="forbid") on every envelope class means a consumer pinning an older purelms-shared will REJECT messages from a newer producer that include the new field. Bump the package version and update every consumer's floor pin in lockstep — this was the discipline unit_block_id followed when it was added in 0.4.0.
Updating the wire format — lockstep consumer updates
When you add a field to an envelope or a value to a shared enum, update these consumer files in the SAME PR:
Python consumers (each pins a purelms-shared>=X.Y.Z floor):
purelms/pyproject.toml(and re-resolve the lockfile)purelms-interactive-tasks/pyproject.tomlpurelms-interactive-tasks/echo/backend/pyproject.tomlpurelms-interactive-tasks/energyplus_single_zone/backend/pyproject.tomlpurelms-interactive-tasks/_template/backend/pyproject.toml- Any other per-InteractiveTask
backend/pyproject.toml
TypeScript consumers (vendor the wire types — extra="forbid" doesn't apply here, but unions out of sync silently break bundles branching on the missing case):
purelms/purelms/static/src/ts/sims/api/types.ts— the LMS-side dispatcher's authoritative TS typespurelms-interactive-tasks/echo/frontend/src/echo.ts— echo's vendored inline typespurelms-interactive-tasks/energyplus_single_zone/frontend/src/types.ts— EnergyPlus's vendored types- Any other per-InteractiveTask
frontend/src/types.ts
The wire-format-sync tests in purelms/purelms/simulations/tests/test_wire_format_sync.py parametrize over each enum value and grep the LMS-side TS file for it — they fail loudly if a Python enum gains a value that doesn't appear in the matching TS union. Run them in CI on every push.
Why this matters: Slice 3d's post-shipping review round 2 caught a real drift — the TS RunStatus union was missing failed_simulation, failed_runtime, and timed_out entirely. Bundles branching on status would have silently failed to render any FAILED_* UI. The lockstep procedure above + the sync tests close that failure mode.
On extra="forbid" — why strictness over flexibility
Every envelope class in this package sets ConfigDict(extra="forbid", frozen=True). That's the choice driving the lockstep-update tax above: a consumer pinning purelms-shared<X will reject a message from a producer at >=X that includes a new field, even when the new field is purely additive and the consumer wouldn't have read it anyway.
We considered the looser alternative — extra="ignore" — and rejected it. The reasoning, recorded here so future contributors don't re-litigate:
- Wire schemas are a credential surface, not just a transport. The output envelope is what evidence manifests and credentials are computed from. A field the producer thinks is meaningful but the consumer silently ignores is the classic shape of evidence drift.
extra="forbid"turns "the consumer is one version behind" from a silent mis-credential into a loud rejection at deserialization time. - The lockstep cost is bounded and visible. Six
pyproject.tomlfloor-pins plus a handful of TS type files — all enumerated in the section above, all enforced by thetest_wire_format_sync.pyparametrized tests. A grep-and-bump operation, not an open-ended migration. The cost is real but pays for itself the first time it catches a drift like Slice 3d round 2'sRunStatusmismatch. - The alternative failure mode is worse. With
extra="ignore", a backend author addingprovenance_hashto the output envelope and an LMS one version behind would silently strip it on read. The credential issued from that run would lack the field. The bug surfaces months later in audit — if at all. Strict mode surfaces it on the first run.
The escape hatch. If the lockstep tax becomes painful in practice, the relaxation is mechanical: change extra="forbid" to extra="ignore" on the relevant envelope class (or all of them) and bump to v2. Old consumers keep working; new producers can add fields without coordinating updates. The decision is reversible — we'd lose the drift-catching property in exchange for looser coupling. We didn't bake the strictness into the wire format itself; it's a per-class Pydantic setting.
Three triggers that would prompt revisiting:
- A third external InteractiveTask author appears (i.e. someone outside the PureLMS core team writing a backend). At that point the shared schema + lockstep-floor-pin workflow becomes a contributor-onboarding tax worth paying for — likely by extracting the TS types into an npm-published
@purelms/shared-typespackage so frontend bundles cannpm installinstead of vendoring. - Backend authors complain that the lockstep update is blocking them — e.g. "I want to add
simulation_seedto my outputs but I can't ship until the LMS bumps itspurelms-sharedfloor." That's the signal the strictness cost has crossed the line from disciplined to obstructive. - A second implementation language wants to write backends (Go, Rust, Node). At that point the right answer probably isn't "port Pydantic" but "generate JSON Schema from these classes and let each language consume it." The strictness invariant survives the language change; the implementation does not.
None of those triggers are tripped today. If they trip in the future, this section is the breadcrumb back to the decision.
Install
uv add purelms-shared
# or
pip install purelms-shared
Quick usage:
from purelms_shared import (
SimulationInputEnvelope,
SimulationOutputEnvelope,
OutputStatus,
Message,
MessageLevel,
)
# Backends construct the output envelope at completion:
out = SimulationOutputEnvelope(
run_id=run_id,
status=OutputStatus.SUCCESS,
outputs={"annual_eui_kbtu_ft2": 47.3},
messages=[
Message(level=MessageLevel.INFO, code="EPLUS.OK", text="Annual run complete"),
],
runtime_seconds=23.4,
)
Architecture context
See the PureLMS simulation backend contract and simulation runtime protocol for the full surrounding design.
Legacy
purelms_shared.energyplus carries an older EnergyPlus-specific result schema used by the (soon-to-be-retired) Modal runtime in purelms-modal. It will be removed when that runtime is phased out per ADR-0002. New code should use the generic envelope schemas above.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters