Repository navigation
feat(bngir): round-trip units, count observables, and builtin rate laws; fail closed on the rest - #33
Conversation
… rate laws; fail closed elsewhere v0.2 roundtrips that silently lost or corrupted semantics, each pinned by a semantic-equality regression test: - builtin_call names rendered verbatim (arrhenius/saturation/hill) produced undefined function calls and an unloadable restored model; render the BNGL keyword spellings and whitelist the compiler's builtin set. - Species observables with count relations rendered R()==2, which does not parse; render bare-molecule form for non-'>' relations and refuse richer shapes BNGL cannot spell. - unit metadata (defaults, definitions, [unit] annotations) was dropped by the reader; re-emit the begin units block and annotations. - Counter observables silently became Species; refuse non-Molecules/Species kinds on write and 'unknown' kinds on read. - barrier patterns rendered 0 -> 0 from the v0.1 field shape; render the transition string the schema defines, and refuse unresolved centers. - reader accepted constructs the writer cannot emit: reverse directions on non-bidirectional rules, modifiers without matching filters, unknown modifiers, unknown builtins - each now a named refusal. - v0.1 cannot represent unit metadata; refuse the write instead of dropping it (semantic_equal surfaces the same refusal). to_bngir(version='0.2') now validates its output with the reader's own validation, so every read gate is also a write gate.
Reproducing the
|
Reviewer's evidence note: a staleness claim in this body has expiredNot editing this PR — The body currently reads: "the C++ extension comes from the shared build, which Measured now, against current Fifteen, not one, and the set is no longer NFsim-only. Two of them change rate-law semantics directly: The conclusions still hold; only the justification rotted. Verified independently:
The second omission, which would mislead anyone re-running the figure. Reproducing What a gate needs attached to it: the ref and the timestamp, in the sentence. "One commit behind, NFsim-only" was true when measured and stopped being true while the session kept merging — a claim about a moving system is a reading at an instant, and this one is published without either anchor. Posted by @RuleWorld at |
What changed
python/bionetgen/bngir.py(versioned, source-free BNG IR) plus its two testfiles. Six reader/writer gaps found by roundtripping hand-built models through
to_bngir/from_bngirbefore any edit (AGENTS.md reproduce-first); eachexpansion is pinned by a semantic-equality regression test, each non-expansion
fails closed with a named
ValueError.Also adds a writer/reader closure:
to_bngir(version="0.2")validates itsown output with the reader's validation (
_load_document_v02) before returning,so every read gate is automatically a write gate and the two lists cannot
drift apart.
Gap -> expanded / refused
builtin_callrendered wire names verbatim:arrhenius(...),saturation(...),hill(...)are not BNGL spellings; restored model has undefined functions and cannot even be re-serialized (RuntimeError: cannot create structural semantic snapshot from invalid model)_BUILTIN_NAMESmaps toArrhenius/Sat/Hill(verified againstBNGLexer.g4spellings); unknown builtins refused by a whitelist that exactly matches C++builtinName()(38 names, excludes"unknown")test_bngir_v02_round_trip_preserves_builtin_rate_laws,test_v02_builtin_calls_render_bngl_keyword_spellings,test_v02_refuses_unknown_builtin_callsSpecies R2 R==2re-emitted asR()==2, which does not parse (ParseError) - BNGL only spells== >= < <=after a bare molecule name>relations; sited/multi-molecule patterns with non->relations refused - the grammar cannot spell themtest_bngir_v02_round_trip_preserves_observable_count_relations(includes sited>terms likeR(l,l).A()>1, which take the pattern branch),test_v02_observable_count_relations_render_bare_molecule_patterns,test_v02_refuses_sited_count_relation_patternsunit_defaults,unit_definitions,[unit]on parameters/compartments/seeds): roundtrip lost units; v0.1 dropped them at write withsemantic_equalblind to the lossbegin unitsblock +[unit]annotations, mirroringBnglWriter. Refused (v0.1): the 0.1 wire/schema has no unit fields and expanding the published schema is outside this lane - named write refusalphysical-unit metadata; use version '0.2', surfaced bysemantic_equaltoo (docstring updated)test_bngir_v02_round_trip_preserves_unit_metadata(asserts nativeunit_defaults, parameter/compartment/seed.uniton the reconstructed model, not just doc equality),test_bngir_v01_refuses_unit_metadata_instead_of_dropping_it,test_v02_renders_unit_defaults_definitions_and_annotations,test_v02_refuses_unsupported_unit_default_roles,test_v02_refuses_builtin_unit_definitionsCounter Ca A()serializes as kind"unknown"and the reader mutated it toSpecies(silent corruption)Counterverbatim); read-side named refusal for kind not in {molecules, species}. Unfixable in 0.2 without a semantic decision that is not mine: the compiled snapshot does not record which non-Molecules/Species kind it was, so rendering anything would be fabrication - that decision (extendCompiledModelobservable kinds) belongs to the C++ lanetest_bngir_v02_refuses_counter_observables_and_v01_preserves_them,test_bngir_v02_rejects_unknown_observable_kindreactants/products/bidirectional) which never exists on the wire ->0 -> 0+ misleading"expression must be an object"; wiretransitionisBarrierPattern::toString()(label + arrow + energy)transitionalone (exactly once - count-pinned). Refused: unresolved reaction centers, missing transition/energytest_v02_barrier_patterns_render_from_transition_text(source.count("Gbar") == 1,source.count("slow:") == 1),test_v02_refuses_unresolved_barrier_centersinclude_*modifier without itsdirection.filtersentry silently droppedtest_bngir_v02_rejects_reverse_direction_on_irreversible_rule,test_bngir_v02_rejects_unrepresentable_rule_modifiers,test_bngir_v02_round_trip_preserves_rule_modifierspopulation_maps: writer emits the section, reader refuses (pre-existing, documented indocs/adr/0001)PopulationMap::rateText(cpp/ast/PopulationMap.hpp:31), which the Python binding does not expose -cpp/bindings/bind_model.cpp:162-166binds onlylabel/pattern/function/args- so expanding needs a binding outside this lanetest_bngir_population_reconstruction_fails_closed(unchanged)Writer-guard accounting (per review)
metadata.unit_definitionsbuiltinbuiltin: falseon every fixture)_payload_v02guard + closure, testedMODIFIER_MODELroundtripcenter_resolvedFindings that outlive this change (candidates for docs, owner
docsHygiene)parse_string/parse_filenever callfinalizeThermodynamicMetadata()-contrast:
cpp/parser/BNGAstVisitor.cpp:2039calls it insideparseModel,while the Python path's
do_parse(cpp/bindings/bind_parser.cpp:25)runs
visitor.visit+takeModel()with no finalize step. Measured consequences through thePython API (command: parse a barrier/driven model, inspect the native
model):
driven_by()is stripped by the source normalizer and its worksilently lost (
has_driving_workprintsFalse, pending options neverflushed);
native.barrier_patternsprints0 []and the barrier leaks asa
__bng3_barrier_N-labelled ordinary rule; ordinary rules are not renamedR<n>. This is upstream of BNGIR and outside this lane (escalated viaorchScope). It is also why the barrier read gate is currentlydefence-in-depth against a path no Python caller can reach - the gate exists
for whoever wires
finalizeThermodynamicMetadata()up.ObservableKind::Unknownis reachable from real models:Counterobservables compile to kind
"unknown"(measured), which is what made thegap-4 mutation possible.
provenance/schemasisread-only in this lane):
bngir-0.2.schema.json'smetadatadef lacksunit_defaults/unit_definitions, so unit-carrying writer output does notvalidate against the schema (schema tests only run on unit-free fixtures);
and
population_mapsitems requirefunctionwhile the C++ writer emitspopulation/rate. Both need schema updates by whoever owns them.Verification (worktree
/Users/akutuva/Documents/BioNetGen/BNG3-py-bngir, commit92c24e7)Harness provenance - on this host
bionetgenis a scikit-build editableinstall whose meta-path finder shadows
sys.path(andPYTHONPATH), so aplain
python -m pytest tests/python -qin any worktree silently testsmain's code. All suite numbers below were produced with a runner that
inserts a finder claiming exactly
bionetgen.bngirfrom this worktree andleaves the rest of the editable chain intact, then prints the binding:
This lane is pure Python (
git status: onlybngir.py+ the two test fileschange); the C++ extension comes from the shared build, which
memWatchbounded to be behind HEAD by exactly one NFsim-only commit (
75b22a7,NFinput_fromCompiled.cpp) - irrelevant to these tests. Post-merge, a plainpython -m pytest tests/python -qbinds the merged tree and is equivalent.No builds were run; no file outside the three in this diff was touched.