diff --git a/.github/fep-lean-pair.json b/.github/fep-lean-pair.json index a05c53fab..de94408c5 100644 --- a/.github/fep-lean-pair.json +++ b/.github/fep-lean-pair.json @@ -1,4 +1,4 @@ { "repository": "ActiveInferenceInstitute/fep_formal", - "revision": "b3e21f6690a22a98cc68b609a818817247fd6868" + "revision": "184ee53f55b526fdc858e2d962eba16e6066fb19" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c2be4813e..0d5c72e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ## [Unreleased] +### Discrete filtering correctness (2026-10-18) + +- PyTorch and NumPyro discrete simulations now predict the next observation's + prior through the previous action's transition matrix. The initial observation + uses the authored prior without an extra prediction. This prevents zero-mass + posterior traces in deterministic-observation models such as `simple_mdp` and + `markov_chain`. +- Generated programs reject zero/nonfinite predictive evidence and fail execution + if the persisted result validation is invalid. Native NumPy reference witnesses + cover asymmetric transitions, every posterior and per-action EFE, the initial + prediction boundary, and failure controls. Input models remain unchanged. + +### E3-FA1 — explicit finite controls (2026-10-18) + +- The optional cpomdp renderer admits versioned source-declared finite actions, + ordered IDs, units and asymmetric state-by-control maps. Invalid declarations, + incompatible modes and unsupported backends refuse explicitly. +- Results distinguish control coordinates from state increments and retain every + policy's pragmatic/epistemic scores and selected action identities. Duplicate + vectors retain their distinct IDs and source tie order. +- Admission bounds total policy-step evaluations across timesteps before + enumeration. Released-wheel NumPy Kalman/EFE witnesses cover asymmetric axes, + correlated covariances, policy scores and negative controls. This bounded + extension leaves the broader E3 and E4 roadmap contracts open. + ### Fixed - Lightweight processing reuses parsed structure during validation, preserves diff --git a/TO-DO.md b/TO-DO.md index ddfcdfa5e..60d126653 100644 --- a/TO-DO.md +++ b/TO-DO.md @@ -1,6 +1,6 @@ # TO-DO — GNN next steps -Updated 2026-10-16. +Updated 2026-10-18. Current software version is **4.1.0**. Its [GitHub release](https://github.com/ActiveInferenceInstitute/Generalized_Notation_Notation/releases/tag/v4.1.0) records exact tag, source, checks and artifacts; version metadata alone does not establish acceptance. Every checkbox below is remaining work. Remove accepted bounded work while retaining its evidence in [CHANGELOG.md](CHANGELOG.md), release receipts and Git @@ -100,7 +100,10 @@ required. evidence. Depends on S3 where structural zeros are required, plus M2/M3/E4. - [ ] **E3 — extend cpomdp control/search capabilities safely (P2).** - Scope: specify new control/search behavior through the + Progress: E3-FA1 adds source-declared ordered finite actions, explicit control + matrices and units, complete policy/component receipts and a total evaluation + cap; its independent numerical witnesses retain a separate bounded scope. + Remaining scope: specify further control/search behavior through the [cpomdp adapter](docs/gnn/implementations/cpomdp.md), preserving passive no-search behavior, explicit EFE defaults and complete policy/source identity. Accept: released constructor compatibility, finite/nonboolean options, diff --git a/docs/development/remaining_roadmap_contracts.md b/docs/development/remaining_roadmap_contracts.md index 15cfa2b53..1670ce055 100644 --- a/docs/development/remaining_roadmap_contracts.md +++ b/docs/development/remaining_roadmap_contracts.md @@ -167,7 +167,7 @@ The maintained compass-rose action set and exhaustive search remain unchanged. Direct options now share typed public admission; invalid options refuse without replacing a caller's existing artifact. -A next extension may admit a source-declared finite action set with an explicit +E3-FA1 now admits a source-declared finite action set with an explicit version, ordered action IDs, units and a control matrix mapping each action into state space. Validate finite, nonboolean components and exact asymmetric control dimensions. Preserve the authored action order for ties and policy identity. @@ -204,3 +204,13 @@ A local package build or an unverified archive URL does not satisfy E5. Preserve failed provider checks and leave the item open when public resolution or artifact hash parity cannot be established. Archive publication and package-index delivery remain distinct operations with their own destination and ownership contracts. + +### E3-FA1 boundary + +The source-declared finite-control implementation is specified in the +[cpomdp guide](../gnn/implementations/cpomdp.md#source-declared-finite-controls-e3-fa1). +It preserves authored action order, distinct IDs for equal vectors, units, +asymmetric control maps and complete per-policy EFE components. Admission also +bounds total evaluations across timesteps. This does not close E3's broader +control/search workstream, admit continuous action spaces, or establish E4's +independent source extraction or universal proof obligations. diff --git a/docs/gnn/implementations/cpomdp.md b/docs/gnn/implementations/cpomdp.md index 9ee13ce7d..abc694f90 100644 --- a/docs/gnn/implementations/cpomdp.md +++ b/docs/gnn/implementations/cpomdp.md @@ -83,3 +83,53 @@ Execution needs no cpomdp-specific module. The execute step runs the rendered sc - [NumPyro](numpyro.md), which renders the same continuous models with a Kalman filter and NUTS - [GNN implementations index](README.md) + +## Source-declared finite controls (E3-FA1) + +A flat continuous source can add this single-line Python-literal mapping to +`InitialParameterization` (or the same mapping to the parsed Python spec). +`goal_mean` and `control_gain` remain required; the gain is not used by EFE mode. + +```text +cpomdp_control = {"contract": "gnn-cpomdp-finite-actions-v1", "version": "thruster-v1", "action_ids": ["forward", "reverse", "coast"], "control_units": ["newton"], "state_units": ["m/s", "m/s", "m/s"], "actions": [[0.3], [-0.15], [0.0]], "control_matrix": [[0.2], [-0.4], [0.7]]} +``` + +For `n` states and `p` controls, `control_matrix` is exactly `n × p` and every +action has `p` components. The transition is +`x_t = F x_(t-1) + control_matrix @ a_(t-1) + noise`. Units label each authored +control and state axis; the matrix maps control units into state increments. +These labels do not certify dimensional analysis. The prior is still over the +first observed state, with no initial predict step. + +All seven keys are mandatory. The contract version is fixed above; `version` +identifies the author's action set. IDs must be unique nonempty strings and +retain source order. Equal vectors with different IDs remain distinct choices; +ties select the first authored policy in Cartesian-product order. At least two +actions are required by released cpomdp 0.4.4. Values must be finite nonboolean +numbers, with no string conversion, normalization or deduplication. Invalid +declarations refuse before replacing an existing artifact. Unknown keys and +versions refuse. These controls require `efe`; `parity` and explicit +`action_scale` overrides refuse. Without this declaration, the existing compass +rose and passive behavior apply. Other backends refuse the declaration. + +Results retain the complete `finite_control_contract`, `action_set_version`, +`control_matrix`, `control_inputs` (in control coordinates), and +`policy_action_ids` for each selected policy. Existing `controls` remain state +increments for downstream continuous comparisons. `policy_score_components` +contains pragmatic and epistemic values for **every** enumerated policy and +timestep; existing selected-component fields remain available. The validated +model digest includes the complete control declaration, including labels. + +Search admission allows at most 4,096 policies, 32,768 policy-step evaluations +per cycle, 262,144 across all timesteps, and 128 MiB estimated allocation. Limits +are checked before enumeration, without computing an unbounded power. The +estimate includes declared arrays and retained score histories; interpreter/JIT +overhead is separate, and the process watchdog remains authoritative. Passive +models allocate no planner. + +Independent NumPy witnesses use source-authored asymmetric state, observation +and control dimensions, nonidentity sensors, nonzero means and correlated +covariances. They compare every posterior and every policy's EFE components +against the released native backend, and check duplicate-vector tie identity. +Changed control maps and an extra initial prediction must disagree. This is a +bounded numerical witness, not a universal extraction or equivalence proof. diff --git a/output/data/manuscript_render_manifest.json b/output/data/manuscript_render_manifest.json index 3e507e744..6d9074292 100644 --- a/output/data/manuscript_render_manifest.json +++ b/output/data/manuscript_render_manifest.json @@ -1,47 +1,47 @@ { - "counts_describe_commit": "690f8c5", + "counts_describe_commit": "4ed8b33", "manifest_version": "gnn_render_manifest_v1", - "receipt_counts_describe_commit": "690f8c5", + "receipt_counts_describe_commit": "4ed8b33", "recorded_by": "uv run python scripts/z_record_manuscript_render_manifest.py", "render_artifacts_normalized_sha256": { "output/pdf/_combined_manuscript.log": "1b46412330c82f3d1a22a5654fa24d3a8926c8b8f2906118c142360d7f6389f9", - "output/pdf/_combined_manuscript.md": "252cbe61cbec70e0d473c982c1b9b40a880b534bf8c04b6cf50d826441260dd0", - "output/pdf/_combined_manuscript.tex": "d46f4d7f1b25ccfad4475df5c653d6d5c272ebefcbd2e9ab1c30e3aab1c6985e" + "output/pdf/_combined_manuscript.md": "f8d7d333a127274c8068e4dd0640ecb2b88544053d69e3930d3dcc0fd1291e93", + "output/pdf/_combined_manuscript.tex": "bbc7b51b5606eb5558518d56e02ab55ddb5377d146993ee68ba810c246778beb" }, "render_artifacts_sha256": { - "output/pdf/_combined_manuscript.log": "57e6fcb5d28a077d1775eff8848f1aa890b294143b3c7be22fe2b2f23aff73d9", - "output/pdf/_combined_manuscript.md": "f72213c0bf4b7050603b49b8ec8ed1179e74593b50c26a3150f761a3052da1f5", - "output/pdf/_combined_manuscript.tex": "7d9211ab16cf9a9caadc4728dbd51d7de9d5db7c165a37ef53cdf5c0a4f21d90" + "output/pdf/_combined_manuscript.log": "be663fe9a5fdb54b885ca1f2aba03bc8325a95c267377af82c2e3e7e49071803", + "output/pdf/_combined_manuscript.md": "27acaca71d11d3a887658df1ae0eebb1683e752b2fda1f72862c36d230c7b13b", + "output/pdf/_combined_manuscript.tex": "ee7f039e06d573825093dae08f580b3c0582401287c5378ddc2a8ca0f725bfdc" }, "render_inputs_normalized_sha256": { "output/manuscript/00_abstract.md": "a66f146435f8300a5bfa2b3fbbc60373dec0e2e3c3fa4de9856f71e8b7eef27e", "output/manuscript/01_introduction.md": "5d0d444605725927d88c415bbd7b2b6d27dfe62d33130cddc3cdbc4669ccb771", "output/manuscript/02_system_context.md": "8da04dfdff5ba163f4c9e944150cff92de19d9ab76e3bf00fda1ee4c279a2e5b", "output/manuscript/03_methods.md": "b23b0a2717f6e4006c6016672ab8ff9f319494a29584e838970e3ffcaa3fe082", - "output/manuscript/04_artifacts_and_evidence.md": "ceb3fedfaca73cf4a0db9c3bbf75f5c8154f162531f0b731283ab8d7f9891f96", - "output/manuscript/05_reproducibility.md": "901bb1e18c872ee37decf5533ffea8b983f71a964b7b3bf764b10276a4265d4c", + "output/manuscript/04_artifacts_and_evidence.md": "53a557380bfe681f84830018b99f51ae4e36f2445e4a78993ace1a5bc53b7e45", + "output/manuscript/05_reproducibility.md": "ef7da6b1d821b4129053458c46bca80048a50a26cad59348aaa5aa21475a45bb", "output/manuscript/06_limitations_and_next_steps.md": "86fdf003b8f486ce262699aca59d5b6bf2cea5b4ff9916acb87399d225a12d6e", "output/manuscript/98_symbols_glossary.md": "4342b4bac17e063cd34f0e0805afe4769048efb22beced4887080ff198577c60", "output/manuscript/99_references.md": "5e80feaa0ff39228501260f8e18f34debc071e1431de9c81a48016cb428aeef2", - "output/manuscript/S01_source_surface.md": "9dfa94d40dd6fa743bb1b2aec6303a2859bb037e407b5596fdbedb2885e8a712", + "output/manuscript/S01_source_surface.md": "5d707901f2045b7d39c925f51551558c1a44928257b7025411b80045b2fd2eea", "output/manuscript/config.yaml": "94d56ef0b383c482d2807b49b9ed5df26705009203746aa4e06a2819313eebfa", "output/manuscript/preamble.md": "ebdb657038ccf007dffefe5e3d5834145b956e6580628356975d741bcd6fb01e", "output/manuscript/references.bib": "ed44f4594867a193123aaf03334e12c2f12e167584d42ac5bc6074c4bfec9f0d" }, "render_inputs_sha256": { - "output/manuscript/00_abstract.md": "99a37dfdb6a65d936247d46e1e1888da18f94bcfe2110f3362da9451fb146467", + "output/manuscript/00_abstract.md": "9f41eac73a95505cccade998c67fc0a2245a1b079f9b6b378ff8307c6f9de50e", "output/manuscript/01_introduction.md": "5d0d444605725927d88c415bbd7b2b6d27dfe62d33130cddc3cdbc4669ccb771", - "output/manuscript/02_system_context.md": "06e660631a2fdfe6755cc2c4724da56209e82f4558e8cdd56c6b389b23ad211e", - "output/manuscript/03_methods.md": "4f6380728f12b632b33ab6e41f9b7516d7d087e430d8e095ff98c0a00938a1ae", - "output/manuscript/04_artifacts_and_evidence.md": "b6a1a593b3abd3b60290551f9253d58c563f389e4bf2a017b41186e46859a744", - "output/manuscript/05_reproducibility.md": "e262cf07ff9736bed96e478cb2b806f7c6a16d68756cef4a54195d4f8cfd047e", + "output/manuscript/02_system_context.md": "3e6e912afca37b0c441b78f8c0059679b9f3698823ebc022cdb1014cda777249", + "output/manuscript/03_methods.md": "d8e8176ceaf484f5081ae251273f5e90ee46ea26cca7e5c1ab7357dd8545b6e6", + "output/manuscript/04_artifacts_and_evidence.md": "58a5d4ddb764086e2acd7cd46f701cda41290cffa8dabfdb9777fc0a4a1cd403", + "output/manuscript/05_reproducibility.md": "c454fc494d108f76b99b4963ce567d7d04b63825336079c8f0d1955d96dbec6b", "output/manuscript/06_limitations_and_next_steps.md": "86fdf003b8f486ce262699aca59d5b6bf2cea5b4ff9916acb87399d225a12d6e", "output/manuscript/98_symbols_glossary.md": "4342b4bac17e063cd34f0e0805afe4769048efb22beced4887080ff198577c60", "output/manuscript/99_references.md": "5e80feaa0ff39228501260f8e18f34debc071e1431de9c81a48016cb428aeef2", - "output/manuscript/S01_source_surface.md": "9dfa94d40dd6fa743bb1b2aec6303a2859bb037e407b5596fdbedb2885e8a712", + "output/manuscript/S01_source_surface.md": "5d707901f2045b7d39c925f51551558c1a44928257b7025411b80045b2fd2eea", "output/manuscript/config.yaml": "94d56ef0b383c482d2807b49b9ed5df26705009203746aa4e06a2819313eebfa", "output/manuscript/preamble.md": "ebdb657038ccf007dffefe5e3d5834145b956e6580628356975d741bcd6fb01e", "output/manuscript/references.bib": "ed44f4594867a193123aaf03334e12c2f12e167584d42ac5bc6074c4bfec9f0d" }, - "variables_sha256": "99256cf1ff620a37045b2f04ee1675b01f2e9778642f61b9a005ca805ec9ff8b" + "variables_sha256": "a8751fd723486c28d7a7975e8126013211ab177f3c2a6a48108936a72762799a" } diff --git a/output/data/manuscript_variables.json b/output/data/manuscript_variables.json index b955c7c91..efe99eeb1 100644 --- a/output/data/manuscript_variables.json +++ b/output/data/manuscript_variables.json @@ -23,7 +23,7 @@ "GNN_FAMILY_TARGET_DIR_COUNT": "9", "GNN_FIRST_AUTHOR": "Daniel Ari Friedman", "GNN_FRAMEWORK_TABLE": "| Framework | Discrete render | Continuous render | Executor status |\n|---|---|---|---|\n| PyMDP | yes | unsupported | executor |\n| RxInfer.jl | yes | yes | executor |\n| ActiveInference.jl | yes | unsupported | executor |\n| JAX | yes | yes | executor |\n| DisCoPy | yes | unsupported | executor |\n| PyTorch | yes | yes | executor |\n| NumPyro | yes | yes | executor |\n| Stan | yes | yes | executor |\n| bnlearn | yes | unsupported | executor |\n| cpomdp | no | yes | executor |\n| THRML | yes | unsupported | executor |\n| ngc-learn | no | yes | executor |\n: Model kinds each render framework supports, read entirely from the flags in `src/gnn/render/framework_registry.py` — the same source as [@tbl:backend_registry]. A continuous cell of `unsupported` is the renderer's own status for continuous-state models; continuous-only targets do not render categorical models. An executor entry declares dispatch support, not acceptance of every model or inference mode; a render-only entry has no Step-12 executor. Read with [@tbl:model_kinds], which groups the same flags by model kind. {#tbl:framework_capability}", - "GNN_GIT_COMMIT": "690f8c5", + "GNN_GIT_COMMIT": "4ed8b33", "GNN_INPUT_FAMILY_DIR_COUNT": "12", "GNN_KEYWORDS": "GNN, active inference, generative models, model notation, pipeline automation", "GNN_LICENSE": "CC-BY-NC-SA-4.0", @@ -45,9 +45,9 @@ "GNN_RELEASE_CODENAME": "Public contracts and native boundaries", "GNN_RELEASE_DATE": "2026-10-08", "GNN_REPO_URL": "https://github.com/ActiveInferenceInstitute/Generalized_Notation_Notation", - "GNN_SRC_LOC": "225574", + "GNN_SRC_LOC": "225800", "GNN_SRC_PACKAGE_COUNT": "44", - "GNN_SRC_PY_FILE_COUNT": "765", + "GNN_SRC_PY_FILE_COUNT": "766", "GNN_STEP_ADVANCED_VIZ": "9", "GNN_STEP_ANALYSIS": "16", "GNN_STEP_AUDIO": "15", @@ -80,9 +80,9 @@ "GNN_STEP_WEBSITE": "20", "GNN_SUBTITLE": "A text language and modular processing pipeline for Active Inference generative models", "GNN_TARGET_DIR_COVERAGE_NOTE": "All 9 registered family target directories lie inside that tree, so a single invocation reaches every registered family.", - "GNN_TEST_COUNT": "6776", - "GNN_TEST_FILE_COUNT": "656", - "GNN_TEST_FUNCTION_COUNT": "6776", + "GNN_TEST_COUNT": "6790", + "GNN_TEST_FILE_COUNT": "658", + "GNN_TEST_FUNCTION_COUNT": "6790", "GNN_TITLE": "GeneralizedNotationNotation: A Text Language for Active Inference Models", "GNN_TOKEN_COUNT": "93", "GNN_TOOL_COUNT": "164", diff --git a/output/data/manuscript_variables_receipt.json b/output/data/manuscript_variables_receipt.json index 6afd15ee4..96f54456d 100644 --- a/output/data/manuscript_variables_receipt.json +++ b/output/data/manuscript_variables_receipt.json @@ -1,5 +1,5 @@ { - "counts_describe_commit": "690f8c5", + "counts_describe_commit": "4ed8b33", "dirty_path_count": 11, "dirty_paths": [ "output/.pipeline/", diff --git a/output/figures/figure_registry.json b/output/figures/figure_registry.json index 22c8cfba2..9c0892cad 100644 --- a/output/figures/figure_registry.json +++ b/output/figures/figure_registry.json @@ -43,7 +43,7 @@ "png_sha256": "0d16072f46643702a2bc7fe393b9468da60682f89a9847eeb2a045d0240ef777", "source_sha256": { "input/model_family_manifest.json": "41adbb30ab861d094823b69669656081bbcb603c7203bd30335555e2f60b9e3c", - "src/gnn/render/framework_registry.py": "baab0f951725f7034af16b17d237513713a203cd222e271b1c912ffdb1dd6033" + "src/gnn/render/framework_registry.py": "b934336e544ca069da84223368fec6129f0abe30dede3144ba5bb7c4bfd7d9ec" } }, { @@ -57,7 +57,7 @@ "input/model_family_manifest.json": "41adbb30ab861d094823b69669656081bbcb603c7203bd30335555e2f60b9e3c", "src/gnn/manuscript/variables.py": "f0770f854a60ccabb86ac034b87646044200974ac0940426a3667851b3827a1d", "src/gnn/pipeline/cross_framework_reliability.py": "f541016765f8c2b843226c9127f011cc77a0e66cbad694e89853db59c6cd8101", - "src/gnn/render/framework_registry.py": "baab0f951725f7034af16b17d237513713a203cd222e271b1c912ffdb1dd6033" + "src/gnn/render/framework_registry.py": "b934336e544ca069da84223368fec6129f0abe30dede3144ba5bb7c4bfd7d9ec" } }, { @@ -71,13 +71,13 @@ "GNN_MCP_TOOL_COUNT": "164", "GNN_SRC_PACKAGE_COUNT": "44", "GNN_STEP_COUNT": "25", - "GNN_TEST_FILE_COUNT": "656", + "GNN_TEST_FILE_COUNT": "658", "GNN_VERSION": "4.1.0" }, "filename": "gnn_repo_metrics.png", "generated_by": "scripts/manuscript_fig_repo_metrics.py", "label": "fig:repo_metrics", - "png_sha256": "2da518030916e213272e776f4fd57638b9151c753a5ef8e2f96ccc9755e60519", + "png_sha256": "ed52c9dc28d588cef5fb1aead3fb526601bfe01d595f813d61e23732c16a33be", "source_sha256": {} }, { diff --git a/output/figures/gnn_repo_metrics.png b/output/figures/gnn_repo_metrics.png index 8406bebc0..ec8a4b455 100644 Binary files a/output/figures/gnn_repo_metrics.png and b/output/figures/gnn_repo_metrics.png differ diff --git a/output/manuscript/00_abstract.md b/output/manuscript/00_abstract.md index 76a1270ba..5d9e7f6a0 100644 --- a/output/manuscript/00_abstract.md +++ b/output/manuscript/00_abstract.md @@ -1,5 +1,5 @@ # Abstract {#sec:abstract} -![The GeneralizedNotationNotation (GNN) pipeline at a glance, generated from the producer's token map at commit 690f8c5. A plain-text specification — the `StateSpaceBlock` declaring the model's tensors, `A`, `B`, `C`, `D`, and `E` for the discrete categorical kind of [@eq:generative_model] through [@eq:policy] and its own matrix vocabulary for the other model kinds — is parsed (Step 3), type-checked and validated (Steps 5–6), rendered as backend-specific code (Step 11), executed (Step 12), and analyzed (Step 16), before artifacts flow into the cross-repository interchange checks against the pinned GEO-INFER and fep_lean pairs. Read the cards left to right: each header names the stage, each pill names the pipeline step (or the source role), and the body states what the stage produces. The validation stage includes the B-tensor orientation check — every discrete `B` literal is read as column-stochastic `B[s', s, a]`, and `--transpose-b` maps row-stochastic textbook literals onto that canonical order — and the execution stage includes the bnlearn lane, which generates runnable Python from the same parsed model and skips with a recorded status when its runtime is absent. The footer states the scale the panel summarizes: 25 pipeline steps, 9 model families, 12 registered render backends with 12 render targets having Step 12 execution adapters, and 164 Model Context Protocol tools.](../output/figures/gnn_graphical_abstract.png){#fig:graphical_abstract width=100%} +![The GeneralizedNotationNotation (GNN) pipeline at a glance, generated from the producer's token map at commit 4ed8b33. A plain-text specification — the `StateSpaceBlock` declaring the model's tensors, `A`, `B`, `C`, `D`, and `E` for the discrete categorical kind of [@eq:generative_model] through [@eq:policy] and its own matrix vocabulary for the other model kinds — is parsed (Step 3), type-checked and validated (Steps 5–6), rendered as backend-specific code (Step 11), executed (Step 12), and analyzed (Step 16), before artifacts flow into the cross-repository interchange checks against the pinned GEO-INFER and fep_lean pairs. Read the cards left to right: each header names the stage, each pill names the pipeline step (or the source role), and the body states what the stage produces. The validation stage includes the B-tensor orientation check — every discrete `B` literal is read as column-stochastic `B[s', s, a]`, and `--transpose-b` maps row-stochastic textbook literals onto that canonical order — and the execution stage includes the bnlearn lane, which generates runnable Python from the same parsed model and skips with a recorded status when its runtime is absent. The footer states the scale the panel summarizes: 25 pipeline steps, 9 model families, 12 registered render backends with 12 render targets having Step 12 execution adapters, and 164 Model Context Protocol tools.](../output/figures/gnn_graphical_abstract.png){#fig:graphical_abstract width=100%} Active Inference offers a unifying account of perception, learning, and action under the free energy principle [@friston2010], yet the generative models at its core are still communicated ad hoc: scattered across prose descriptions, bespoke notebooks, and framework-specific code that rarely agree. This fragmentation makes published models hard to reproduce, compare, or port between tools, and it raises the barrier for newcomers learning the formalism [@parr2022]. GeneralizedNotationNotation (GNN) addresses this gap with a standardized, human- and machine-readable text language for specifying Active Inference generative models, paired with a 25-step processing pipeline that transforms a single specification into validation, visualization, simulation, and analysis artifacts [@gnn2023]. A GNN file denotes a generative model, and the language lets that model take the several shapes the field actually uses: the notation blocks a file declares — categorical `A`/`B`/`C`/`D`[/`E`] tensors, linear-Gaussian system matrices `F`/`H`/`Q`/`R` with a Gaussian prior, per-agent and per-level compositions — fix its model kind, which the pipeline classifies structurally and carries through rendering and execution, recording explicitly which registered backends can render and run a model of each kind and which report it unsupported. GNN's "Triple Play" treats each model as three coordinated views — a textual specification, graphical visualizations, and executable cognitive models — so that outputs share an authored source and can be checked for consistency through identity, parameter, semantic, and numerical receipts. The framework spans 38 exemplar specifications (29 discrete-state and 9 continuous linear-Gaussian) across 9 model families and 12 registered rendering backends, with explicit gates for cross-format semantic fidelity and cross-framework reliability. GNN 3.0.0 layered safe-by-design long-running orchestration on top of this language and pipeline — durable observation streams, resumable run sessions, and auditable container plans that generate, validate, and replay data only, with no live infrastructure mutation. We describe the language, the model kinds it denotes, the pipeline architecture, and the validation contracts and source-bound checks used to assess specification round trips and admitted executions across simulation backends — with coverage gaps recorded as explicit profiled-unsupported statuses rather than silently omitted — providing inspectable infrastructure for reproducing and comparing Active Inference models within the recorded capability and verification boundaries. diff --git a/output/manuscript/02_system_context.md b/output/manuscript/02_system_context.md index a2b428757..96a28faac 100644 --- a/output/manuscript/02_system_context.md +++ b/output/manuscript/02_system_context.md @@ -128,7 +128,7 @@ The governance runs in the direction the taxonomy promises: classification is co The pipeline is a fixed sequence of 25 numbered steps, 0–24, each a self-contained stage that consumes the artifacts of its predecessors and writes typed outputs for those that follow. Early steps parse and type-check the GNN text and validate it against the language schema; middle steps render visualizations, export the model to executable backends, and run simulations; later steps perform analysis, reporting, and downstream integration. The declared orchestration prerequisites among the steps form the directed acyclic graph shown in [@fig:pipeline]. The graph records which steps must precede others; artifact receipts separately identify source inputs and produced files, including optional enrichments. -![The 25-step GNN processing pipeline as a directed acyclic graph, arranged in rowwise topological order and colored by execution phase (legend above the nodes). Each node is the thin step orchestrator under `src/gnn/` named in [@tbl:pipeline_steps]; each arrow is a required orchestration prerequisite parsed from the Data Dependency Graph block of `src/gnn/STEP_INDEX.md`, which mirrors the live step registry. Parsing (Step 3) precedes many later steps. Optional artifact enrichments are documented separately and are not shown as required edges. The figure is generated from `src/gnn/STEP_INDEX.md` at commit 690f8c5; its footer restates the step count, the number of required prerequisites, and the layout rule.](../output/figures/gnn_pipeline_dag.png){#fig:pipeline width=90%} +![The 25-step GNN processing pipeline as a directed acyclic graph, arranged in rowwise topological order and colored by execution phase (legend above the nodes). Each node is the thin step orchestrator under `src/gnn/` named in [@tbl:pipeline_steps]; each arrow is a required orchestration prerequisite parsed from the Data Dependency Graph block of `src/gnn/STEP_INDEX.md`, which mirrors the live step registry. Parsing (Step 3) precedes many later steps. Optional artifact enrichments are documented separately and are not shown as required edges. The figure is generated from `src/gnn/STEP_INDEX.md` at commit 4ed8b33; its footer restates the step count, the number of required prerequisites, and the layout rule.](../output/figures/gnn_pipeline_dag.png){#fig:pipeline width=90%} The per-step responsibilities are enumerated in [@tbl:pipeline_steps]; each row names a step and the transformation it owns within the 0–24 range. @@ -167,6 +167,6 @@ This staged design keeps the architecture modular. The implementation is organiz The reason for separating a single written language from a multi-stage pipeline is the design goal GNN calls the Triple Play: one model specification, three coordinated modes of existence. The same GNN text supplies a human-readable model description, the declared state-space and factor structure for graphical visualization, and input to supported executable renderers. Source identity and validation receipts connect these representations to the authored model; runtime readiness and model-kind admission govern which simulations can execute. @fig:triple_play depicts these three faces and the shared specification at their center. -![The Triple Play: one GNN text specification (left) and its three coordinated projections (right) — the human-readable text form, graphical model visualizations, and executable simulation code. All three arrows originate at the same source box to show their shared parsed input. Source binding, parameter validation, and semantic-fidelity checks provide evidence of agreement among the projections. The executable node summarizes 12 of 12 registered render targets with implemented Step 12 execution adapters and previews three by name; execution depends on runtime readiness, admitted model kind, and a successful source-bound run receipt. Counts are producer tokens at commit 690f8c5, and the panel is generated by `scripts/manuscript_fig_triple_play.py`. Take-away: a model is authored once and then read, seen, and run from that single source.](../output/figures/gnn_triple_play.png){#fig:triple_play width=70%} +![The Triple Play: one GNN text specification (left) and its three coordinated projections (right) — the human-readable text form, graphical model visualizations, and executable simulation code. All three arrows originate at the same source box to show their shared parsed input. Source binding, parameter validation, and semantic-fidelity checks provide evidence of agreement among the projections. The executable node summarizes 12 of 12 registered render targets with implemented Step 12 execution adapters and previews three by name; execution depends on runtime readiness, admitted model kind, and a successful source-bound run receipt. Counts are producer tokens at commit 4ed8b33, and the panel is generated by `scripts/manuscript_fig_triple_play.py`. Take-away: a model is authored once and then read, seen, and run from that single source.](../output/figures/gnn_triple_play.png){#fig:triple_play width=70%} Each face derives from a shared authored source. The text is the contract a researcher reads and reviews; the visualizations expose the declared factor-graph structure for inspection and communication; and the executable rendering targets inference frameworks, connecting the written specification to the discrete message-passing libraries for categorical Active Inference [@heins2022], to the reactive message-passing and probabilistic-programming engines that carry the linear-Gaussian kind, and to the broader tooling ecosystem for compositional model representation [@defelice2021]. Common source identity makes the projections traceable, while parameter values, tensor orientation, model-kind semantics, and source-bound numerical results still require validation to establish agreement. A supported model can therefore be read, seen, and run without authoring a separate specification for each purpose [@smith2022]. The Triple Play is governed per kind: renderers and executors apply the computed classification and their declared capability boundaries, and receipts distinguish successful, unsupported, skipped, partial, and failed outcomes for the selected model. diff --git a/output/manuscript/03_methods.md b/output/manuscript/03_methods.md index a631cd745..4d1c10396 100644 --- a/output/manuscript/03_methods.md +++ b/output/manuscript/03_methods.md @@ -56,7 +56,7 @@ Because rendering is kind-aware, the registry is best read as a capability matri The registry surface of these backends — registry key, display name, implementation language, and which of them the cross-framework gate compares — is summarized in @fig:backend_matrix; the family-by-backend coverage itself is shown in @fig:family_matrix. -![The GNN rendering backend registry as a table: one row per registered backend, with its registry key, display name, implementation language, whether a render-output subdirectory exists under `src/gnn/render/`, and its role in the cross-framework reference comparison. Amber rows are the backends the reliability gate profiles for the continuous family; the tan row (Stan, ngc-learn) is declared by that family but sits outside the maintained set, so it is never profiled; a red Render-Subdir cell would mean a registry entry with no subdirectory. The table is read directly from `src/gnn/render/framework_registry.py` and the model-family manifest at commit 690f8c5, and its footer summarizes the row counts. Take-away: registration, render presence, and profiling status are three different claims, and the table keeps them apart.](../output/figures/gnn_backend_capability_matrix.png){#fig:backend_matrix width=85%} +![The GNN rendering backend registry as a table: one row per registered backend, with its registry key, display name, implementation language, whether a render-output subdirectory exists under `src/gnn/render/`, and its role in the cross-framework reference comparison. Amber rows are the backends the reliability gate profiles for the continuous family; the tan row (Stan, ngc-learn) is declared by that family but sits outside the maintained set, so it is never profiled; a red Render-Subdir cell would mean a registry entry with no subdirectory. The table is read directly from `src/gnn/render/framework_registry.py` and the model-family manifest at commit 4ed8b33, and its footer summarizes the row counts. Take-away: registration, render presence, and profiling status are three different claims, and the table keeps them apart.](../output/figures/gnn_backend_capability_matrix.png){#fig:backend_matrix width=85%} ### Rendering by Model Kind @@ -96,4 +96,4 @@ The final group of stages turns execution results into interpretable evidence. A ## Reproducibility and Auto-Injection -Every count reported in this manuscript is emitted by the producer `scripts/z_generate_manuscript_variables.py` from the repository state at commit 690f8c5, not typed by hand. The producer reads that commit's source surfaces directly — the step modules, the source tree, the model-family manifest, and the framework registry — together with the project's maintained ledgers, notably the Model Context Protocol tool audit (`src/gnn/mcp/audit_report.json`, itself regenerated by the test suite). From these it emits a manifest of named tokens — counts of pipeline steps and their modules, source packages and files, model families, backends, example files partitioned by model kind, tests, and tools — plus the two capability tables this manuscript injects: the model-kind table ([@tbl:model_kinds]) and the framework capability matrix ([@tbl:framework_capability]). The renderer substitutes the tokens into the prose at build time. Filesystem-derived counts are recomputed on every run; ledger-derived counts (such as the backend registry) are re-read from a fixed repository state, so a fixed repository state yields the same values, and authors are prohibited from hard-coding any number that has a corresponding token. This makes the manuscript a faithful, regenerable description of the system it documents: when the 25-step pipeline, the 12 rendering backends, or the 38-file example corpus change — or when the discrete-to-continuous exemplar balance shifts — the reported numbers change with them on the next build, and `scripts/check_manuscript_tokens.py` fails the build if a section reintroduces a literal that a token already owns. +Every count reported in this manuscript is emitted by the producer `scripts/z_generate_manuscript_variables.py` from the repository state at commit 4ed8b33, not typed by hand. The producer reads that commit's source surfaces directly — the step modules, the source tree, the model-family manifest, and the framework registry — together with the project's maintained ledgers, notably the Model Context Protocol tool audit (`src/gnn/mcp/audit_report.json`, itself regenerated by the test suite). From these it emits a manifest of named tokens — counts of pipeline steps and their modules, source packages and files, model families, backends, example files partitioned by model kind, tests, and tools — plus the two capability tables this manuscript injects: the model-kind table ([@tbl:model_kinds]) and the framework capability matrix ([@tbl:framework_capability]). The renderer substitutes the tokens into the prose at build time. Filesystem-derived counts are recomputed on every run; ledger-derived counts (such as the backend registry) are re-read from a fixed repository state, so a fixed repository state yields the same values, and authors are prohibited from hard-coding any number that has a corresponding token. This makes the manuscript a faithful, regenerable description of the system it documents: when the 25-step pipeline, the 12 rendering backends, or the 38-file example corpus change — or when the discrete-to-continuous exemplar balance shifts — the reported numbers change with them on the next build, and `scripts/check_manuscript_tokens.py` fails the build if a section reintroduces a literal that a token already owns. diff --git a/output/manuscript/04_artifacts_and_evidence.md b/output/manuscript/04_artifacts_and_evidence.md index 7f134bbf8..d23ffc8a3 100644 --- a/output/manuscript/04_artifacts_and_evidence.md +++ b/output/manuscript/04_artifacts_and_evidence.md @@ -1,6 +1,6 @@ # Artifacts and Evidence {#sec:artifacts_evidence} -This section reports what the project has actually produced and how each quantitative claim is grounded. Every number below is substituted at render time from the deterministic producer, which reads the repository state at commit 690f8c5, so each figure here is regenerated from the artifacts it describes rather than transcribed. +This section reports what the project has actually produced and how each quantitative claim is grounded. Every number below is substituted at render time from the deterministic producer, which reads the repository state at commit 4ed8b33, so each figure here is regenerated from the artifacts it describes rather than transcribed. ## Model-Family Coverage @@ -25,7 +25,7 @@ Families that declare a capability axis (the continuous family declares the cont The family-by-framework structure is shown in @fig:family_matrix, which renders the coverage matrix directly from the family registry rather than from a hand-maintained table. -![Model-family coverage across the registered rendering backends: one row per family from `input/model_family_manifest.json`, one column per backend from `src/gnn/render/framework_registry.py`, and a green cell wherever the family declares that backend in its `frameworks` field. The right-hand count states how many backends each family declares; the grid is deliberately sparse — most families declare a single backend, and only continuous, discrete, and gridworld declare several. Read it as declared intent, not as profiled outcomes: the gates described below supply the outcomes. The matrix is generated from the two registries at commit 690f8c5.](../output/figures/gnn_family_framework_matrix.png){#fig:family_matrix width=85%} +![Model-family coverage across the registered rendering backends: one row per family from `input/model_family_manifest.json`, one column per backend from `src/gnn/render/framework_registry.py`, and a green cell wherever the family declares that backend in its `frameworks` field. The right-hand count states how many backends each family declares; the grid is deliberately sparse — most families declare a single backend, and only continuous, discrete, and gridworld declare several. Read it as declared intent, not as profiled outcomes: the gates described below supply the outcomes. The matrix is generated from the two registries at commit 4ed8b33.](../output/figures/gnn_family_framework_matrix.png){#fig:family_matrix width=85%} These families are not illustrative prose: they are the inputs over which the parser, the type checker, and the cross-framework code generators are exercised, and they are the substrate for the reliability gates described next. @@ -43,11 +43,11 @@ A third interchange check extends the same discipline across repositories: `scri ## Repository Scale -The repository's scale is itself evidence of the surface that the gates and pipeline cover, and it is reported in @fig:repo_metrics directly from the tracked files at commit 690f8c5. +The repository's scale is itself evidence of the surface that the gates and pipeline cover, and it is reported in @fig:repo_metrics directly from the tracked files at commit 4ed8b33. -![Repository-scale counts on a logarithmic axis: pipeline steps, model families, registered backends, render-target execution adapters, Model Context Protocol tools, source packages, test files, example models, and documentation files. Every bar is annotated with its exact value, and every value is a producer token read from `output/data/manuscript_variables.json` at commit 690f8c5 — the same token map that substitutes the prose counts. The figure-freshness suite binds the committed image digest and its recorded producer inputs and counts; semantic content and visual correctness require their own review. The two backend bars are deliberately distinct: *registered backends* (12) counts render targets, *render-target adapters* (12) counts the render-target subset with implemented Step 12 execution adapters. It excludes the separate execution-only Lean bridge and is not a count of observed native runs. Read the chart as the scale of the surface the pipeline maintains, not as a quality measure.](../output/figures/gnn_repo_metrics.png){#fig:repo_metrics width=80%} +![Repository-scale counts on a logarithmic axis: pipeline steps, model families, registered backends, render-target execution adapters, Model Context Protocol tools, source packages, test files, example models, and documentation files. Every bar is annotated with its exact value, and every value is a producer token read from `output/data/manuscript_variables.json` at commit 4ed8b33 — the same token map that substitutes the prose counts. The figure-freshness suite binds the committed image digest and its recorded producer inputs and counts; semantic content and visual correctness require their own review. The two backend bars are deliberately distinct: *registered backends* (12) counts render targets, *render-target adapters* (12) counts the render-target subset with implemented Step 12 execution adapters. It excludes the separate execution-only Lean bridge and is not a count of observed native runs. Read the chart as the scale of the surface the pipeline maintains, not as a quality measure.](../output/figures/gnn_repo_metrics.png){#fig:repo_metrics width=80%} -The test suite comprises 656 test files containing 6776 test functions, exercising a source base of 765 Python files across 44 packages (225574 lines of source). The pipeline's step modules — the thin orchestrators named in [@tbl:pipeline_steps] — number 25, one per numbered step. The Model Context Protocol surface — which exposes GNN's capabilities to external agents and tools — provides 164 tools across 36 modules. The pipeline itself runs as 25 steps (0–24), and 7 figure artifacts from the rendering of figures, models, and reports are committed under `output/`, of which 7 are the manuscript's own. +The test suite comprises 658 test files containing 6790 test functions, exercising a source base of 766 Python files across 44 packages (225800 lines of source). The pipeline's step modules — the thin orchestrators named in [@tbl:pipeline_steps] — number 25, one per numbered step. The Model Context Protocol surface — which exposes GNN's capabilities to external agents and tools — provides 164 tools across 36 modules. The pipeline itself runs as 25 steps (0–24), and 7 figure artifacts from the rendering of figures, models, and reports are committed under `output/`, of which 7 are the manuscript's own. ## Claim Discipline diff --git a/output/manuscript/05_reproducibility.md b/output/manuscript/05_reproducibility.md index 758fd3ee5..72bc4f4de 100644 --- a/output/manuscript/05_reproducibility.md +++ b/output/manuscript/05_reproducibility.md @@ -72,7 +72,7 @@ uv run --frozen python scripts/pipeline/stage_03_render.py \ The render needs a LaTeX installation providing the packages listed in `manuscript/preamble.md` plus `seqsplit`; the template guards `seqsplit` with `\IfFileExists`, so a missing copy degrades rather than failing the build. -Because the variables file is regenerated before rendering, the counts in the rendered PDF track the repository state at the commit recorded in `output/data/manuscript_variables.json` (690f8c5): a code change that alters, for example, the test inventory (656 test files, 6776 test functions) propagates into the prose on the next regeneration without any manual editing. +Because the variables file is regenerated before rendering, the counts in the rendered PDF track the repository state at the commit recorded in `output/data/manuscript_variables.json` (4ed8b33): a code change that alters, for example, the test inventory (658 test files, 6790 test functions) propagates into the prose on the next regeneration without any manual editing. ## Reproducibility Contract diff --git a/output/manuscript/S01_source_surface.md b/output/manuscript/S01_source_surface.md index 1b8d4a2d0..1b39f55e9 100644 --- a/output/manuscript/S01_source_surface.md +++ b/output/manuscript/S01_source_surface.md @@ -2,7 +2,7 @@ This supplement records the top-level source surfaces a reader or reviewer should inspect before turning any prose in the main manuscript into a verifiable claim. Each surface below is authored material under version control; the generated artifacts it produces are described separately so that the boundary between hand-written source and reproducible output stays explicit. -The `src/` tree is the executable core of GNN. It is organized into 44 Python packages spanning 765 source files and roughly 225574 lines of code. Alongside these packages sit the 25 top-level step modules (0–24) that implement the numbered pipeline as thin orchestrators delegating into the packages, each module owning one stage of the progression from a parsed GNN text model through visualization, type checking, code export, and executable cognitive simulation. A claim about declared pipeline behavior should identify its source implementation; a claim about actual execution additionally requires a source-bound command and result receipt. +The `src/` tree is the executable core of GNN. It is organized into 44 Python packages spanning 766 source files and roughly 225800 lines of code. Alongside these packages sit the 25 top-level step modules (0–24) that implement the numbered pipeline as thin orchestrators delegating into the packages, each module owning one stage of the progression from a parsed GNN text model through visualization, type checking, code export, and executable cognitive simulation. A claim about declared pipeline behavior should identify its source implementation; a claim about actual execution additionally requires a source-bound command and result receipt. The `input/` tree holds the model corpora that exercise the pipeline. Its `gnn_files/` subtree contains 12 corpus directories organized by model family, together totaling 38 example files — 29 discrete-state specifications and 9 continuous linear-Gaussian specifications, the partition the manuscript's model-kind accounting uses ([@sec:model_kinds]). The cold-start index `input/gnn_files/INDEX.md` catalogues every exemplar with its kind and task folder, and the `recursive/` directory is reserved for bounded `--autonomous` proposal-loop runs, holding no committed models. Every model file under `input/` lives in that subtree, so the 38-file count covers the whole tree. A `model_family_manifest.json` enumerates the 9 registered families and their target directories. This manifest is the authoritative registry that downstream steps and the manuscript variable producer read when they report family counts and cross-framework coverage; it should be consulted directly rather than inferred from directory listings. diff --git a/output/pdf/GeneralizedNotationNotation_combined.pdf b/output/pdf/GeneralizedNotationNotation_combined.pdf index 916abc4f3..bf07f1143 100644 Binary files a/output/pdf/GeneralizedNotationNotation_combined.pdf and b/output/pdf/GeneralizedNotationNotation_combined.pdf differ diff --git a/output/pdf/_combined_manuscript.aux b/output/pdf/_combined_manuscript.aux index 6e13a2560..0ac439cd6 100644 --- a/output/pdf/_combined_manuscript.aux +++ b/output/pdf/_combined_manuscript.aux @@ -12,9 +12,9 @@ \citation{gnn2023} \@writefile{toc}{\contentsline {section}{\numberline {1}Abstract}{2}{section.1}\protected@file@percent } \newlabel{sec:abstract}{{1}{2}{Abstract}{section.1}{}} -\@writefile{lof}{\contentsline {figure}{\numberline {1}{\ignorespaces The GeneralizedNotationNotation (GNN) pipeline at a glance, generated from the producer's token map at commit 690f8c5. A plain-text specification --- the \texttt {StateSpaceBlock} declaring the model's tensors, \texttt {A}, \texttt {B}, \texttt {C}, \texttt {D}, and \texttt {E} for the discrete categorical kind of eq.~\ref {eq:generative_model} through eq.~\ref {eq:policy} and its own matrix vocabulary for the other model kinds --- is parsed (Step 3), type-checked and validated (Steps 5--6), rendered as backend-specific code (Step 11), executed (Step 12), and analyzed (Step 16), before artifacts flow into the cross-repository interchange checks against the pinned GEO-INFER and fep\_lean pairs. Read the cards left to right: each header names the stage, each pill names the pipeline step (or the source role), and the body states what the stage produces. The validation stage includes the B-tensor orientation check --- every discrete \texttt {B} literal is read as column-stochastic \texttt {B{[}s\textquotesingle {},\ s,\ a{]}}, and \texttt {-\/-transpose-b} maps row-stochastic textbook literals onto that canonical order --- and the execution stage includes the bnlearn lane, which generates runnable Python from the same parsed model and skips with a recorded status when its runtime is absent. The footer states the scale the panel summarizes: 25 pipeline steps, 9 model families, 12 registered render backends with 12 render targets having Step 12 execution adapters, and 164 Model Context Protocol tools.}}{2}{figure.caption.2}\protected@file@percent } +\@writefile{lof}{\contentsline {figure}{\numberline {1}{\ignorespaces The GeneralizedNotationNotation (GNN) pipeline at a glance, generated from the producer's token map at commit 4ed8b33. A plain-text specification --- the \texttt {StateSpaceBlock} declaring the model's tensors, \texttt {A}, \texttt {B}, \texttt {C}, \texttt {D}, and \texttt {E} for the discrete categorical kind of eq.~\ref {eq:generative_model} through eq.~\ref {eq:policy} and its own matrix vocabulary for the other model kinds --- is parsed (Step 3), type-checked and validated (Steps 5--6), rendered as backend-specific code (Step 11), executed (Step 12), and analyzed (Step 16), before artifacts flow into the cross-repository interchange checks against the pinned GEO-INFER and fep\_lean pairs. Read the cards left to right: each header names the stage, each pill names the pipeline step (or the source role), and the body states what the stage produces. The validation stage includes the B-tensor orientation check --- every discrete \texttt {B} literal is read as column-stochastic \texttt {B{[}s\textquotesingle {},\ s,\ a{]}}, and \texttt {-\/-transpose-b} maps row-stochastic textbook literals onto that canonical order --- and the execution stage includes the bnlearn lane, which generates runnable Python from the same parsed model and skips with a recorded status when its runtime is absent. The footer states the scale the panel summarizes: 25 pipeline steps, 9 model families, 12 registered render backends with 12 render targets having Step 12 execution adapters, and 164 Model Context Protocol tools.}}{2}{figure.caption.2}\protected@file@percent } \providecommand*\caption@xref[2]{\@setref\relax\@undefined{#1}} -\newlabel{fig:graphical_abstract}{{1}{2}{The GeneralizedNotationNotation (GNN) pipeline at a glance, generated from the producer's token map at commit 690f8c5. A plain-text specification --- the \protect \texttt {StateSpaceBlock} declaring the model's tensors, \protect \texttt {A}, \protect \texttt {B}, \protect \texttt {C}, \protect \texttt {D}, and \protect \texttt {E} for the discrete categorical kind of eq.~\ref {eq:generative_model} through eq.~\ref {eq:policy} and its own matrix vocabulary for the other model kinds --- is parsed (Step 3), type-checked and validated (Steps 5--6), rendered as backend-specific code (Step 11), executed (Step 12), and analyzed (Step 16), before artifacts flow into the cross-repository interchange checks against the pinned GEO-INFER and fep\_lean pairs. Read the cards left to right: each header names the stage, each pill names the pipeline step (or the source role), and the body states what the stage produces. The validation stage includes the B-tensor orientation check --- every discrete \protect \texttt {B} literal is read as column-stochastic \protect \texttt {B{[}s\textquotesingle {},\ s,\ a{]}}, and \protect \texttt {-\/-transpose-b} maps row-stochastic textbook literals onto that canonical order --- and the execution stage includes the bnlearn lane, which generates runnable Python from the same parsed model and skips with a recorded status when its runtime is absent. The footer states the scale the panel summarizes: 25 pipeline steps, 9 model families, 12 registered render backends with 12 render targets having Step 12 execution adapters, and 164 Model Context Protocol tools}{figure.caption.2}{}} +\newlabel{fig:graphical_abstract}{{1}{2}{The GeneralizedNotationNotation (GNN) pipeline at a glance, generated from the producer's token map at commit 4ed8b33. A plain-text specification --- the \protect \texttt {StateSpaceBlock} declaring the model's tensors, \protect \texttt {A}, \protect \texttt {B}, \protect \texttt {C}, \protect \texttt {D}, and \protect \texttt {E} for the discrete categorical kind of eq.~\ref {eq:generative_model} through eq.~\ref {eq:policy} and its own matrix vocabulary for the other model kinds --- is parsed (Step 3), type-checked and validated (Steps 5--6), rendered as backend-specific code (Step 11), executed (Step 12), and analyzed (Step 16), before artifacts flow into the cross-repository interchange checks against the pinned GEO-INFER and fep\_lean pairs. Read the cards left to right: each header names the stage, each pill names the pipeline step (or the source role), and the body states what the stage produces. The validation stage includes the B-tensor orientation check --- every discrete \protect \texttt {B} literal is read as column-stochastic \protect \texttt {B{[}s\textquotesingle {},\ s,\ a{]}}, and \protect \texttt {-\/-transpose-b} maps row-stochastic textbook literals onto that canonical order --- and the execution stage includes the bnlearn lane, which generates runnable Python from the same parsed model and skips with a recorded status when its runtime is absent. The footer states the scale the panel summarizes: 25 pipeline steps, 9 model families, 12 registered render backends with 12 render targets having Step 12 execution adapters, and 164 Model Context Protocol tools}{figure.caption.2}{}} \citation{friston2010} \citation{dacosta2020} \citation{parr2022,smith2022} @@ -73,8 +73,8 @@ \newlabel{what-the-kind-classification-governs}{{3.2.5}{8}{What the Kind Classification Governs}{subsubsection.3.2.5}{}} \@writefile{toc}{\contentsline {subsection}{\numberline {3.3}The Processing Pipeline}{9}{subsection.3.3}\protected@file@percent } \newlabel{the-processing-pipeline}{{3.3}{9}{The Processing Pipeline}{subsection.3.3}{}} -\@writefile{lof}{\contentsline {figure}{\numberline {2}{\ignorespaces The 25-step GNN processing pipeline as a directed acyclic graph, arranged in rowwise topological order and colored by execution phase (legend above the nodes). Each node is the thin step orchestrator under \texttt {src/gnn/} named in tbl.~\ref {tbl:pipeline_steps}; each arrow is a required orchestration prerequisite parsed from the Data Dependency Graph block of \breaktt {src/gnn/STEP\_INDEX.md}, which mirrors the live step registry. Parsing (Step 3) precedes many later steps. Optional artifact enrichments are documented separately and are not shown as required edges. The figure is generated from \breaktt {src/gnn/STEP\_INDEX.md} at commit 690f8c5; its footer restates the step count, the number of required prerequisites, and the layout rule.}}{9}{figure.caption.14}\protected@file@percent } -\newlabel{fig:pipeline}{{2}{9}{The 25-step GNN processing pipeline as a directed acyclic graph, arranged in rowwise topological order and colored by execution phase (legend above the nodes). Each node is the thin step orchestrator under \protect \texttt {src/gnn/} named in tbl.~\ref {tbl:pipeline_steps}; each arrow is a required orchestration prerequisite parsed from the Data Dependency Graph block of \protect \breaktt {src/gnn/STEP\_INDEX.md}, which mirrors the live step registry. Parsing (Step 3) precedes many later steps. Optional artifact enrichments are documented separately and are not shown as required edges. The figure is generated from \protect \breaktt {src/gnn/STEP\_INDEX.md} at commit 690f8c5; its footer restates the step count, the number of required prerequisites, and the layout rule}{figure.caption.14}{}} +\@writefile{lof}{\contentsline {figure}{\numberline {2}{\ignorespaces The 25-step GNN processing pipeline as a directed acyclic graph, arranged in rowwise topological order and colored by execution phase (legend above the nodes). Each node is the thin step orchestrator under \texttt {src/gnn/} named in tbl.~\ref {tbl:pipeline_steps}; each arrow is a required orchestration prerequisite parsed from the Data Dependency Graph block of \breaktt {src/gnn/STEP\_INDEX.md}, which mirrors the live step registry. Parsing (Step 3) precedes many later steps. Optional artifact enrichments are documented separately and are not shown as required edges. The figure is generated from \breaktt {src/gnn/STEP\_INDEX.md} at commit 4ed8b33; its footer restates the step count, the number of required prerequisites, and the layout rule.}}{9}{figure.caption.14}\protected@file@percent } +\newlabel{fig:pipeline}{{2}{9}{The 25-step GNN processing pipeline as a directed acyclic graph, arranged in rowwise topological order and colored by execution phase (legend above the nodes). Each node is the thin step orchestrator under \protect \texttt {src/gnn/} named in tbl.~\ref {tbl:pipeline_steps}; each arrow is a required orchestration prerequisite parsed from the Data Dependency Graph block of \protect \breaktt {src/gnn/STEP\_INDEX.md}, which mirrors the live step registry. Parsing (Step 3) precedes many later steps. Optional artifact enrichments are documented separately and are not shown as required edges. The figure is generated from \protect \breaktt {src/gnn/STEP\_INDEX.md} at commit 4ed8b33; its footer restates the step count, the number of required prerequisites, and the layout rule}{figure.caption.14}{}} \gdef \LT@ii {\LT@entry {1}{196.59068pt}\LT@entry {1}{154.94664pt}\LT@entry @@ -86,8 +86,8 @@ \citation{smith2022} \@writefile{toc}{\contentsline {subsection}{\numberline {3.4}The Triple Play}{11}{subsection.3.4}\protected@file@percent } \newlabel{the-triple-play}{{3.4}{11}{The Triple Play}{subsection.3.4}{}} -\@writefile{lof}{\contentsline {figure}{\numberline {3}{\ignorespaces The Triple Play: one GNN text specification (left) and its three coordinated projections (right) --- the human-readable text form, graphical model visualizations, and executable simulation code. All three arrows originate at the same source box to show their shared parsed input. Source binding, parameter validation, and semantic-fidelity checks provide evidence of agreement among the projections. The executable node summarizes 12 of 12 registered render targets with implemented Step 12 execution adapters and previews three by name; execution depends on runtime readiness, admitted model kind, and a successful source-bound run receipt. Counts are producer tokens at commit 690f8c5, and the panel is generated by \breaktt {scripts/manuscript\_fig\_triple\_play.py}. Take-away: a model is authored once and then read, seen, and run from that single source.}}{11}{figure.caption.15}\protected@file@percent } -\newlabel{fig:triple_play}{{3}{11}{The Triple Play: one GNN text specification (left) and its three coordinated projections (right) --- the human-readable text form, graphical model visualizations, and executable simulation code. All three arrows originate at the same source box to show their shared parsed input. Source binding, parameter validation, and semantic-fidelity checks provide evidence of agreement among the projections. The executable node summarizes 12 of 12 registered render targets with implemented Step 12 execution adapters and previews three by name; execution depends on runtime readiness, admitted model kind, and a successful source-bound run receipt. Counts are producer tokens at commit 690f8c5, and the panel is generated by \protect \breaktt {scripts/manuscript\_fig\_triple\_play.py}. Take-away: a model is authored once and then read, seen, and run from that single source}{figure.caption.15}{}} +\@writefile{lof}{\contentsline {figure}{\numberline {3}{\ignorespaces The Triple Play: one GNN text specification (left) and its three coordinated projections (right) --- the human-readable text form, graphical model visualizations, and executable simulation code. All three arrows originate at the same source box to show their shared parsed input. Source binding, parameter validation, and semantic-fidelity checks provide evidence of agreement among the projections. The executable node summarizes 12 of 12 registered render targets with implemented Step 12 execution adapters and previews three by name; execution depends on runtime readiness, admitted model kind, and a successful source-bound run receipt. Counts are producer tokens at commit 4ed8b33, and the panel is generated by \breaktt {scripts/manuscript\_fig\_triple\_play.py}. Take-away: a model is authored once and then read, seen, and run from that single source.}}{11}{figure.caption.15}\protected@file@percent } +\newlabel{fig:triple_play}{{3}{11}{The Triple Play: one GNN text specification (left) and its three coordinated projections (right) --- the human-readable text form, graphical model visualizations, and executable simulation code. All three arrows originate at the same source box to show their shared parsed input. Source binding, parameter validation, and semantic-fidelity checks provide evidence of agreement among the projections. The executable node summarizes 12 of 12 registered render targets with implemented Step 12 execution adapters and previews three by name; execution depends on runtime readiness, admitted model kind, and a successful source-bound run receipt. Counts are producer tokens at commit 4ed8b33, and the panel is generated by \protect \breaktt {scripts/manuscript\_fig\_triple\_play.py}. Take-away: a model is authored once and then read, seen, and run from that single source}{figure.caption.15}{}} \citation{rangarajan2026hierarchicalSuccessor} \citation{heins2022} \@writefile{toc}{\contentsline {section}{\numberline {4}Methods}{12}{section.4}\protected@file@percent } @@ -113,8 +113,8 @@ \newlabel{tbl:framework_capability}{{4}{13}{Model kinds each render framework supports, read entirely from the flags in \protect \breaktt {src/gnn/render/framework\_registry.py} --- the same source as tbl.~\ref {tbl:backend_registry}. A continuous cell of \protect \texttt {unsupported} is the renderer's own status for continuous-state models; continuous-only targets do not render categorical models. An executor entry declares dispatch support, not acceptance of every model or inference mode; a render-only entry has no Step-12 executor. Read with tbl.~\ref {tbl:model_kinds}, which groups the same flags by model kind}{table.4}{}} \@writefile{toc}{\contentsline {subsubsection}{\numberline {4.3.1}Rendering by Model Kind}{13}{subsubsection.4.3.1}\protected@file@percent } \newlabel{rendering-by-model-kind}{{4.3.1}{13}{Rendering by Model Kind}{subsubsection.4.3.1}{}} -\@writefile{lof}{\contentsline {figure}{\numberline {4}{\ignorespaces The GNN rendering backend registry as a table: one row per registered backend, with its registry key, display name, implementation language, whether a render-output subdirectory exists under \texttt {src/gnn/render/}, and its role in the cross-framework reference comparison. Amber rows are the backends the reliability gate profiles for the continuous family; the tan row (Stan, ngc-learn) is declared by that family but sits outside the maintained set, so it is never profiled; a red Render-Subdir cell would mean a registry entry with no subdirectory. The table is read directly from \breaktt {src/gnn/render/framework\_registry.py} and the model-family manifest at commit 690f8c5, and its footer summarizes the row counts. Take-away: registration, render presence, and profiling status are three different claims, and the table keeps them apart.}}{14}{figure.caption.16}\protected@file@percent } -\newlabel{fig:backend_matrix}{{4}{14}{The GNN rendering backend registry as a table: one row per registered backend, with its registry key, display name, implementation language, whether a render-output subdirectory exists under \protect \texttt {src/gnn/render/}, and its role in the cross-framework reference comparison. Amber rows are the backends the reliability gate profiles for the continuous family; the tan row (Stan, ngc-learn) is declared by that family but sits outside the maintained set, so it is never profiled; a red Render-Subdir cell would mean a registry entry with no subdirectory. The table is read directly from \protect \breaktt {src/gnn/render/framework\_registry.py} and the model-family manifest at commit 690f8c5, and its footer summarizes the row counts. Take-away: registration, render presence, and profiling status are three different claims, and the table keeps them apart}{figure.caption.16}{}} +\@writefile{lof}{\contentsline {figure}{\numberline {4}{\ignorespaces The GNN rendering backend registry as a table: one row per registered backend, with its registry key, display name, implementation language, whether a render-output subdirectory exists under \texttt {src/gnn/render/}, and its role in the cross-framework reference comparison. Amber rows are the backends the reliability gate profiles for the continuous family; the tan row (Stan, ngc-learn) is declared by that family but sits outside the maintained set, so it is never profiled; a red Render-Subdir cell would mean a registry entry with no subdirectory. The table is read directly from \breaktt {src/gnn/render/framework\_registry.py} and the model-family manifest at commit 4ed8b33, and its footer summarizes the row counts. Take-away: registration, render presence, and profiling status are three different claims, and the table keeps them apart.}}{14}{figure.caption.16}\protected@file@percent } +\newlabel{fig:backend_matrix}{{4}{14}{The GNN rendering backend registry as a table: one row per registered backend, with its registry key, display name, implementation language, whether a render-output subdirectory exists under \protect \texttt {src/gnn/render/}, and its role in the cross-framework reference comparison. Amber rows are the backends the reliability gate profiles for the continuous family; the tan row (Stan, ngc-learn) is declared by that family but sits outside the maintained set, so it is never profiled; a red Render-Subdir cell would mean a registry entry with no subdirectory. The table is read directly from \protect \breaktt {src/gnn/render/framework\_registry.py} and the model-family manifest at commit 4ed8b33, and its footer summarizes the row counts. Take-away: registration, render presence, and profiling status are three different claims, and the table keeps them apart}{figure.caption.16}{}} \citation{heins2022} \citation{bagaev2021reactiveMessagePassing} \citation{defelice2021} @@ -144,14 +144,14 @@ \newlabel{tbl:model_families}{{5}{18}{Model families declared in \protect \breaktt {input/model\_family\_manifest.json} with the simulation frameworks each family targets and a generated description. The \emph {Frameworks} column is the family's declared targets, verbatim from the manifest; the capability splits appended to some descriptions are generated from \protect \breaktt {src/gnn/render/framework\_registry.py} flags, not authored in the manifest, so they cannot disagree with the registry. Read with tbl.~\ref {tbl:backend_registry} (what each backend is) and fig.~\ref {fig:family_matrix} (the same coverage as a matrix)}{table.5}{}} \citation{dacosta2020} \citation{heins2022} -\@writefile{lof}{\contentsline {figure}{\numberline {6}{\ignorespaces Model-family coverage across the registered rendering backends: one row per family from \breaktt {input/model\_family\_manifest.json}, one column per backend from \breaktt {src/gnn/render/framework\_registry.py}, and a green cell wherever the family declares that backend in its \texttt {frameworks} field. The right-hand count states how many backends each family declares; the grid is deliberately sparse --- most families declare a single backend, and only continuous, discrete, and gridworld declare several. Read it as declared intent, not as profiled outcomes: the gates described below supply the outcomes. The matrix is generated from the two registries at commit 690f8c5.}}{19}{figure.caption.18}\protected@file@percent } -\newlabel{fig:family_matrix}{{6}{19}{Model-family coverage across the registered rendering backends: one row per family from \protect \breaktt {input/model\_family\_manifest.json}, one column per backend from \protect \breaktt {src/gnn/render/framework\_registry.py}, and a green cell wherever the family declares that backend in its \protect \texttt {frameworks} field. The right-hand count states how many backends each family declares; the grid is deliberately sparse --- most families declare a single backend, and only continuous, discrete, and gridworld declare several. Read it as declared intent, not as profiled outcomes: the gates described below supply the outcomes. The matrix is generated from the two registries at commit 690f8c5}{figure.caption.18}{}} +\@writefile{lof}{\contentsline {figure}{\numberline {6}{\ignorespaces Model-family coverage across the registered rendering backends: one row per family from \breaktt {input/model\_family\_manifest.json}, one column per backend from \breaktt {src/gnn/render/framework\_registry.py}, and a green cell wherever the family declares that backend in its \texttt {frameworks} field. The right-hand count states how many backends each family declares; the grid is deliberately sparse --- most families declare a single backend, and only continuous, discrete, and gridworld declare several. Read it as declared intent, not as profiled outcomes: the gates described below supply the outcomes. The matrix is generated from the two registries at commit 4ed8b33.}}{19}{figure.caption.18}\protected@file@percent } +\newlabel{fig:family_matrix}{{6}{19}{Model-family coverage across the registered rendering backends: one row per family from \protect \breaktt {input/model\_family\_manifest.json}, one column per backend from \protect \breaktt {src/gnn/render/framework\_registry.py}, and a green cell wherever the family declares that backend in its \protect \texttt {frameworks} field. The right-hand count states how many backends each family declares; the grid is deliberately sparse --- most families declare a single backend, and only continuous, discrete, and gridworld declare several. Read it as declared intent, not as profiled outcomes: the gates described below supply the outcomes. The matrix is generated from the two registries at commit 4ed8b33}{figure.caption.18}{}} \@writefile{toc}{\contentsline {subsection}{\numberline {5.2}Semantic-Fidelity and Cross-Framework Reliability Gates}{19}{subsection.5.2}\protected@file@percent } \newlabel{semantic-fidelity-and-cross-framework-reliability-gates}{{5.2}{19}{Semantic-Fidelity and Cross-Framework Reliability Gates}{subsection.5.2}{}} \@writefile{toc}{\contentsline {subsection}{\numberline {5.3}Repository Scale}{20}{subsection.5.3}\protected@file@percent } \newlabel{repository-scale}{{5.3}{20}{Repository Scale}{subsection.5.3}{}} -\@writefile{lof}{\contentsline {figure}{\numberline {7}{\ignorespaces Repository-scale counts on a logarithmic axis: pipeline steps, model families, registered backends, render-target execution adapters, Model Context Protocol tools, source packages, test files, example models, and documentation files. Every bar is annotated with its exact value, and every value is a producer token read from \breaktt {output/data/manuscript\_variables.json} at commit 690f8c5 --- the same token map that substitutes the prose counts. The figure-freshness suite binds the committed image digest and its recorded producer inputs and counts; semantic content and visual correctness require their own review. The two backend bars are deliberately distinct: \emph {registered backends} (12) counts render targets, \emph {render-target adapters} (12) counts the render-target subset with implemented Step 12 execution adapters. It excludes the separate execution-only Lean bridge and is not a count of observed native runs. Read the chart as the scale of the surface the pipeline maintains, not as a quality measure.}}{20}{figure.caption.19}\protected@file@percent } -\newlabel{fig:repo_metrics}{{7}{20}{Repository-scale counts on a logarithmic axis: pipeline steps, model families, registered backends, render-target execution adapters, Model Context Protocol tools, source packages, test files, example models, and documentation files. Every bar is annotated with its exact value, and every value is a producer token read from \protect \breaktt {output/data/manuscript\_variables.json} at commit 690f8c5 --- the same token map that substitutes the prose counts. The figure-freshness suite binds the committed image digest and its recorded producer inputs and counts; semantic content and visual correctness require their own review. The two backend bars are deliberately distinct: \emph {registered backends} (12) counts render targets, \emph {render-target adapters} (12) counts the render-target subset with implemented Step 12 execution adapters. It excludes the separate execution-only Lean bridge and is not a count of observed native runs. Read the chart as the scale of the surface the pipeline maintains, not as a quality measure}{figure.caption.19}{}} +\@writefile{lof}{\contentsline {figure}{\numberline {7}{\ignorespaces Repository-scale counts on a logarithmic axis: pipeline steps, model families, registered backends, render-target execution adapters, Model Context Protocol tools, source packages, test files, example models, and documentation files. Every bar is annotated with its exact value, and every value is a producer token read from \breaktt {output/data/manuscript\_variables.json} at commit 4ed8b33 --- the same token map that substitutes the prose counts. The figure-freshness suite binds the committed image digest and its recorded producer inputs and counts; semantic content and visual correctness require their own review. The two backend bars are deliberately distinct: \emph {registered backends} (12) counts render targets, \emph {render-target adapters} (12) counts the render-target subset with implemented Step 12 execution adapters. It excludes the separate execution-only Lean bridge and is not a count of observed native runs. Read the chart as the scale of the surface the pipeline maintains, not as a quality measure.}}{20}{figure.caption.19}\protected@file@percent } +\newlabel{fig:repo_metrics}{{7}{20}{Repository-scale counts on a logarithmic axis: pipeline steps, model families, registered backends, render-target execution adapters, Model Context Protocol tools, source packages, test files, example models, and documentation files. Every bar is annotated with its exact value, and every value is a producer token read from \protect \breaktt {output/data/manuscript\_variables.json} at commit 4ed8b33 --- the same token map that substitutes the prose counts. The figure-freshness suite binds the committed image digest and its recorded producer inputs and counts; semantic content and visual correctness require their own review. The two backend bars are deliberately distinct: \emph {registered backends} (12) counts render targets, \emph {render-target adapters} (12) counts the render-target subset with implemented Step 12 execution adapters. It excludes the separate execution-only Lean bridge and is not a count of observed native runs. Read the chart as the scale of the surface the pipeline maintains, not as a quality measure}{figure.caption.19}{}} \citation{friston2010,parr2022} \@writefile{toc}{\contentsline {subsection}{\numberline {5.4}Claim Discipline}{21}{subsection.5.4}\protected@file@percent } \newlabel{claim-discipline}{{5.4}{21}{Claim Discipline}{subsection.5.4}{}} diff --git a/output/pdf/_combined_manuscript.log b/output/pdf/_combined_manuscript.log index 8b08f787d..a1017968b 100644 --- a/output/pdf/_combined_manuscript.log +++ b/output/pdf/_combined_manuscript.log @@ -1,4 +1,4 @@ -This is XeTeX, Version 3.141592653-2.6-0.999995 (TeX Live 2023/Debian) (preloaded format=xelatex 2026.10.18) 18 OCT 2026 06:27 +This is XeTeX, Version 3.141592653-2.6-0.999995 (TeX Live 2023/Debian) (preloaded format=xelatex 2026.10.18) 18 OCT 2026 21:59 entering extended mode %&-line parsing enabled. **_combined_manuscript.tex diff --git a/output/pdf/_combined_manuscript.md b/output/pdf/_combined_manuscript.md index d928d7790..4db0fe837 100644 --- a/output/pdf/_combined_manuscript.md +++ b/output/pdf/_combined_manuscript.md @@ -1,6 +1,6 @@ # Abstract {#sec:abstract} -![The GeneralizedNotationNotation (GNN) pipeline at a glance, generated from the producer's token map at commit 690f8c5. A plain-text specification — the `StateSpaceBlock` declaring the model's tensors, `A`, `B`, `C`, `D`, and `E` for the discrete categorical kind of [@eq:generative_model] through [@eq:policy] and its own matrix vocabulary for the other model kinds — is parsed (Step 3), type-checked and validated (Steps 5–6), rendered as backend-specific code (Step 11), executed (Step 12), and analyzed (Step 16), before artifacts flow into the cross-repository interchange checks against the pinned GEO-INFER and fep_lean pairs. Read the cards left to right: each header names the stage, each pill names the pipeline step (or the source role), and the body states what the stage produces. The validation stage includes the B-tensor orientation check — every discrete `B` literal is read as column-stochastic `B[s', s, a]`, and `--transpose-b` maps row-stochastic textbook literals onto that canonical order — and the execution stage includes the bnlearn lane, which generates runnable Python from the same parsed model and skips with a recorded status when its runtime is absent. The footer states the scale the panel summarizes: 25 pipeline steps, 9 model families, 12 registered render backends with 12 render targets having Step 12 execution adapters, and 164 Model Context Protocol tools.](../figures/gnn_graphical_abstract.png){#fig:graphical_abstract width=100%} +![The GeneralizedNotationNotation (GNN) pipeline at a glance, generated from the producer's token map at commit 4ed8b33. A plain-text specification — the `StateSpaceBlock` declaring the model's tensors, `A`, `B`, `C`, `D`, and `E` for the discrete categorical kind of [@eq:generative_model] through [@eq:policy] and its own matrix vocabulary for the other model kinds — is parsed (Step 3), type-checked and validated (Steps 5–6), rendered as backend-specific code (Step 11), executed (Step 12), and analyzed (Step 16), before artifacts flow into the cross-repository interchange checks against the pinned GEO-INFER and fep_lean pairs. Read the cards left to right: each header names the stage, each pill names the pipeline step (or the source role), and the body states what the stage produces. The validation stage includes the B-tensor orientation check — every discrete `B` literal is read as column-stochastic `B[s', s, a]`, and `--transpose-b` maps row-stochastic textbook literals onto that canonical order — and the execution stage includes the bnlearn lane, which generates runnable Python from the same parsed model and skips with a recorded status when its runtime is absent. The footer states the scale the panel summarizes: 25 pipeline steps, 9 model families, 12 registered render backends with 12 render targets having Step 12 execution adapters, and 164 Model Context Protocol tools.](../figures/gnn_graphical_abstract.png){#fig:graphical_abstract width=100%} Active Inference offers a unifying account of perception, learning, and action under the free energy principle [@friston2010], yet the generative models at its core are still communicated ad hoc: scattered across prose descriptions, bespoke notebooks, and framework-specific code that rarely agree. This fragmentation makes published models hard to reproduce, compare, or port between tools, and it raises the barrier for newcomers learning the formalism [@parr2022]. GeneralizedNotationNotation (GNN) addresses this gap with a standardized, human- and machine-readable text language for specifying Active Inference generative models, paired with a 25-step processing pipeline that transforms a single specification into validation, visualization, simulation, and analysis artifacts [@gnn2023]. A GNN file denotes a generative model, and the language lets that model take the several shapes the field actually uses: the notation blocks a file declares — categorical `A`/`B`/`C`/`D`[/`E`] tensors, linear-Gaussian system matrices `F`/`H`/`Q`/`R` with a Gaussian prior, per-agent and per-level compositions — fix its model kind, which the pipeline classifies structurally and carries through rendering and execution, recording explicitly which registered backends can render and run a model of each kind and which report it unsupported. GNN's "Triple Play" treats each model as three coordinated views — a textual specification, graphical visualizations, and executable cognitive models — so that outputs share an authored source and can be checked for consistency through identity, parameter, semantic, and numerical receipts. The framework spans 38 exemplar specifications (29 discrete-state and 9 continuous linear-Gaussian) across 9 model families and 12 registered rendering backends, with explicit gates for cross-format semantic fidelity and cross-framework reliability. GNN 3.0.0 layered safe-by-design long-running orchestration on top of this language and pipeline — durable observation streams, resumable run sessions, and auditable container plans that generate, validate, and replay data only, with no live infrastructure mutation. We describe the language, the model kinds it denotes, the pipeline architecture, and the validation contracts and source-bound checks used to assess specification round trips and admitted executions across simulation backends — with coverage gaps recorded as explicit profiled-unsupported statuses rather than silently omitted — providing inspectable infrastructure for reproducing and comparing Active Inference models within the recorded capability and verification boundaries. @@ -177,7 +177,7 @@ The governance runs in the direction the taxonomy promises: classification is co The pipeline is a fixed sequence of 25 numbered steps, 0–24, each a self-contained stage that consumes the artifacts of its predecessors and writes typed outputs for those that follow. Early steps parse and type-check the GNN text and validate it against the language schema; middle steps render visualizations, export the model to executable backends, and run simulations; later steps perform analysis, reporting, and downstream integration. The declared orchestration prerequisites among the steps form the directed acyclic graph shown in [@fig:pipeline]. The graph records which steps must precede others; artifact receipts separately identify source inputs and produced files, including optional enrichments. -![The 25-step GNN processing pipeline as a directed acyclic graph, arranged in rowwise topological order and colored by execution phase (legend above the nodes). Each node is the thin step orchestrator under `src/gnn/` named in [@tbl:pipeline_steps]; each arrow is a required orchestration prerequisite parsed from the Data Dependency Graph block of `src/gnn/STEP_INDEX.md`, which mirrors the live step registry. Parsing (Step 3) precedes many later steps. Optional artifact enrichments are documented separately and are not shown as required edges. The figure is generated from `src/gnn/STEP_INDEX.md` at commit 690f8c5; its footer restates the step count, the number of required prerequisites, and the layout rule.](../figures/gnn_pipeline_dag.png){#fig:pipeline width=90%} +![The 25-step GNN processing pipeline as a directed acyclic graph, arranged in rowwise topological order and colored by execution phase (legend above the nodes). Each node is the thin step orchestrator under `src/gnn/` named in [@tbl:pipeline_steps]; each arrow is a required orchestration prerequisite parsed from the Data Dependency Graph block of `src/gnn/STEP_INDEX.md`, which mirrors the live step registry. Parsing (Step 3) precedes many later steps. Optional artifact enrichments are documented separately and are not shown as required edges. The figure is generated from `src/gnn/STEP_INDEX.md` at commit 4ed8b33; its footer restates the step count, the number of required prerequisites, and the layout rule.](../figures/gnn_pipeline_dag.png){#fig:pipeline width=90%} The per-step responsibilities are enumerated in [@tbl:pipeline_steps]; each row names a step and the transformation it owns within the 0–24 range. @@ -216,7 +216,7 @@ This staged design keeps the architecture modular. The implementation is organiz The reason for separating a single written language from a multi-stage pipeline is the design goal GNN calls the Triple Play: one model specification, three coordinated modes of existence. The same GNN text supplies a human-readable model description, the declared state-space and factor structure for graphical visualization, and input to supported executable renderers. Source identity and validation receipts connect these representations to the authored model; runtime readiness and model-kind admission govern which simulations can execute. @fig:triple_play depicts these three faces and the shared specification at their center. -![The Triple Play: one GNN text specification (left) and its three coordinated projections (right) — the human-readable text form, graphical model visualizations, and executable simulation code. All three arrows originate at the same source box to show their shared parsed input. Source binding, parameter validation, and semantic-fidelity checks provide evidence of agreement among the projections. The executable node summarizes 12 of 12 registered render targets with implemented Step 12 execution adapters and previews three by name; execution depends on runtime readiness, admitted model kind, and a successful source-bound run receipt. Counts are producer tokens at commit 690f8c5, and the panel is generated by `scripts/manuscript_fig_triple_play.py`. Take-away: a model is authored once and then read, seen, and run from that single source.](../figures/gnn_triple_play.png){#fig:triple_play width=70%} +![The Triple Play: one GNN text specification (left) and its three coordinated projections (right) — the human-readable text form, graphical model visualizations, and executable simulation code. All three arrows originate at the same source box to show their shared parsed input. Source binding, parameter validation, and semantic-fidelity checks provide evidence of agreement among the projections. The executable node summarizes 12 of 12 registered render targets with implemented Step 12 execution adapters and previews three by name; execution depends on runtime readiness, admitted model kind, and a successful source-bound run receipt. Counts are producer tokens at commit 4ed8b33, and the panel is generated by `scripts/manuscript_fig_triple_play.py`. Take-away: a model is authored once and then read, seen, and run from that single source.](../figures/gnn_triple_play.png){#fig:triple_play width=70%} Each face derives from a shared authored source. The text is the contract a researcher reads and reviews; the visualizations expose the declared factor-graph structure for inspection and communication; and the executable rendering targets inference frameworks, connecting the written specification to the discrete message-passing libraries for categorical Active Inference [@heins2022], to the reactive message-passing and probabilistic-programming engines that carry the linear-Gaussian kind, and to the broader tooling ecosystem for compositional model representation [@defelice2021]. Common source identity makes the projections traceable, while parameter values, tensor orientation, model-kind semantics, and source-bound numerical results still require validation to establish agreement. A supported model can therefore be read, seen, and run without authoring a separate specification for each purpose [@smith2022]. The Triple Play is governed per kind: renderers and executors apply the computed classification and their declared capability boundaries, and receipts distinguish successful, unsupported, skipped, partial, and failed outcomes for the selected model. @@ -285,7 +285,7 @@ Because rendering is kind-aware, the registry is best read as a capability matri The registry surface of these backends — registry key, display name, implementation language, and which of them the cross-framework gate compares — is summarized in @fig:backend_matrix; the family-by-backend coverage itself is shown in @fig:family_matrix. -![The GNN rendering backend registry as a table: one row per registered backend, with its registry key, display name, implementation language, whether a render-output subdirectory exists under `src/gnn/render/`, and its role in the cross-framework reference comparison. Amber rows are the backends the reliability gate profiles for the continuous family; the tan row (Stan, ngc-learn) is declared by that family but sits outside the maintained set, so it is never profiled; a red Render-Subdir cell would mean a registry entry with no subdirectory. The table is read directly from `src/gnn/render/framework_registry.py` and the model-family manifest at commit 690f8c5, and its footer summarizes the row counts. Take-away: registration, render presence, and profiling status are three different claims, and the table keeps them apart.](../figures/gnn_backend_capability_matrix.png){#fig:backend_matrix width=85%} +![The GNN rendering backend registry as a table: one row per registered backend, with its registry key, display name, implementation language, whether a render-output subdirectory exists under `src/gnn/render/`, and its role in the cross-framework reference comparison. Amber rows are the backends the reliability gate profiles for the continuous family; the tan row (Stan, ngc-learn) is declared by that family but sits outside the maintained set, so it is never profiled; a red Render-Subdir cell would mean a registry entry with no subdirectory. The table is read directly from `src/gnn/render/framework_registry.py` and the model-family manifest at commit 4ed8b33, and its footer summarizes the row counts. Take-away: registration, render presence, and profiling status are three different claims, and the table keeps them apart.](../figures/gnn_backend_capability_matrix.png){#fig:backend_matrix width=85%} ### Rendering by Model Kind @@ -325,7 +325,7 @@ The final group of stages turns execution results into interpretable evidence. A ## Reproducibility and Auto-Injection -Every count reported in this manuscript is emitted by the producer `scripts/z_generate_manuscript_variables.py` from the repository state at commit 690f8c5, not typed by hand. The producer reads that commit's source surfaces directly — the step modules, the source tree, the model-family manifest, and the framework registry — together with the project's maintained ledgers, notably the Model Context Protocol tool audit (`src/gnn/mcp/audit_report.json`, itself regenerated by the test suite). From these it emits a manifest of named tokens — counts of pipeline steps and their modules, source packages and files, model families, backends, example files partitioned by model kind, tests, and tools — plus the two capability tables this manuscript injects: the model-kind table ([@tbl:model_kinds]) and the framework capability matrix ([@tbl:framework_capability]). The renderer substitutes the tokens into the prose at build time. Filesystem-derived counts are recomputed on every run; ledger-derived counts (such as the backend registry) are re-read from a fixed repository state, so a fixed repository state yields the same values, and authors are prohibited from hard-coding any number that has a corresponding token. This makes the manuscript a faithful, regenerable description of the system it documents: when the 25-step pipeline, the 12 rendering backends, or the 38-file example corpus change — or when the discrete-to-continuous exemplar balance shifts — the reported numbers change with them on the next build, and `scripts/check_manuscript_tokens.py` fails the build if a section reintroduces a literal that a token already owns. +Every count reported in this manuscript is emitted by the producer `scripts/z_generate_manuscript_variables.py` from the repository state at commit 4ed8b33, not typed by hand. The producer reads that commit's source surfaces directly — the step modules, the source tree, the model-family manifest, and the framework registry — together with the project's maintained ledgers, notably the Model Context Protocol tool audit (`src/gnn/mcp/audit_report.json`, itself regenerated by the test suite). From these it emits a manifest of named tokens — counts of pipeline steps and their modules, source packages and files, model families, backends, example files partitioned by model kind, tests, and tools — plus the two capability tables this manuscript injects: the model-kind table ([@tbl:model_kinds]) and the framework capability matrix ([@tbl:framework_capability]). The renderer substitutes the tokens into the prose at build time. Filesystem-derived counts are recomputed on every run; ledger-derived counts (such as the backend registry) are re-read from a fixed repository state, so a fixed repository state yields the same values, and authors are prohibited from hard-coding any number that has a corresponding token. This makes the manuscript a faithful, regenerable description of the system it documents: when the 25-step pipeline, the 12 rendering backends, or the 38-file example corpus change — or when the discrete-to-continuous exemplar balance shifts — the reported numbers change with them on the next build, and `scripts/check_manuscript_tokens.py` fails the build if a section reintroduces a literal that a token already owns. @@ -336,7 +336,7 @@ Every count reported in this manuscript is emitted by the producer `scripts/z_ge # Artifacts and Evidence {#sec:artifacts_evidence} -This section reports what the project has actually produced and how each quantitative claim is grounded. Every number below is substituted at render time from the deterministic producer, which reads the repository state at commit 690f8c5, so each figure here is regenerated from the artifacts it describes rather than transcribed. +This section reports what the project has actually produced and how each quantitative claim is grounded. Every number below is substituted at render time from the deterministic producer, which reads the repository state at commit 4ed8b33, so each figure here is regenerated from the artifacts it describes rather than transcribed. ## Model-Family Coverage @@ -361,7 +361,7 @@ Families that declare a capability axis (the continuous family declares the cont The family-by-framework structure is shown in @fig:family_matrix, which renders the coverage matrix directly from the family registry rather than from a hand-maintained table. -![Model-family coverage across the registered rendering backends: one row per family from `input/model_family_manifest.json`, one column per backend from `src/gnn/render/framework_registry.py`, and a green cell wherever the family declares that backend in its `frameworks` field. The right-hand count states how many backends each family declares; the grid is deliberately sparse — most families declare a single backend, and only continuous, discrete, and gridworld declare several. Read it as declared intent, not as profiled outcomes: the gates described below supply the outcomes. The matrix is generated from the two registries at commit 690f8c5.](../figures/gnn_family_framework_matrix.png){#fig:family_matrix width=85%} +![Model-family coverage across the registered rendering backends: one row per family from `input/model_family_manifest.json`, one column per backend from `src/gnn/render/framework_registry.py`, and a green cell wherever the family declares that backend in its `frameworks` field. The right-hand count states how many backends each family declares; the grid is deliberately sparse — most families declare a single backend, and only continuous, discrete, and gridworld declare several. Read it as declared intent, not as profiled outcomes: the gates described below supply the outcomes. The matrix is generated from the two registries at commit 4ed8b33.](../figures/gnn_family_framework_matrix.png){#fig:family_matrix width=85%} These families are not illustrative prose: they are the inputs over which the parser, the type checker, and the cross-framework code generators are exercised, and they are the substrate for the reliability gates described next. @@ -379,11 +379,11 @@ A third interchange check extends the same discipline across repositories: `scri ## Repository Scale -The repository's scale is itself evidence of the surface that the gates and pipeline cover, and it is reported in @fig:repo_metrics directly from the tracked files at commit 690f8c5. +The repository's scale is itself evidence of the surface that the gates and pipeline cover, and it is reported in @fig:repo_metrics directly from the tracked files at commit 4ed8b33. -![Repository-scale counts on a logarithmic axis: pipeline steps, model families, registered backends, render-target execution adapters, Model Context Protocol tools, source packages, test files, example models, and documentation files. Every bar is annotated with its exact value, and every value is a producer token read from `output/data/manuscript_variables.json` at commit 690f8c5 — the same token map that substitutes the prose counts. The figure-freshness suite binds the committed image digest and its recorded producer inputs and counts; semantic content and visual correctness require their own review. The two backend bars are deliberately distinct: *registered backends* (12) counts render targets, *render-target adapters* (12) counts the render-target subset with implemented Step 12 execution adapters. It excludes the separate execution-only Lean bridge and is not a count of observed native runs. Read the chart as the scale of the surface the pipeline maintains, not as a quality measure.](../figures/gnn_repo_metrics.png){#fig:repo_metrics width=80%} +![Repository-scale counts on a logarithmic axis: pipeline steps, model families, registered backends, render-target execution adapters, Model Context Protocol tools, source packages, test files, example models, and documentation files. Every bar is annotated with its exact value, and every value is a producer token read from `output/data/manuscript_variables.json` at commit 4ed8b33 — the same token map that substitutes the prose counts. The figure-freshness suite binds the committed image digest and its recorded producer inputs and counts; semantic content and visual correctness require their own review. The two backend bars are deliberately distinct: *registered backends* (12) counts render targets, *render-target adapters* (12) counts the render-target subset with implemented Step 12 execution adapters. It excludes the separate execution-only Lean bridge and is not a count of observed native runs. Read the chart as the scale of the surface the pipeline maintains, not as a quality measure.](../figures/gnn_repo_metrics.png){#fig:repo_metrics width=80%} -The test suite comprises 656 test files containing 6776 test functions, exercising a source base of 765 Python files across 44 packages (225574 lines of source). The pipeline's step modules — the thin orchestrators named in [@tbl:pipeline_steps] — number 25, one per numbered step. The Model Context Protocol surface — which exposes GNN's capabilities to external agents and tools — provides 164 tools across 36 modules. The pipeline itself runs as 25 steps (0–24), and 7 figure artifacts from the rendering of figures, models, and reports are committed under `output/`, of which 7 are the manuscript's own. +The test suite comprises 658 test files containing 6790 test functions, exercising a source base of 766 Python files across 44 packages (225800 lines of source). The pipeline's step modules — the thin orchestrators named in [@tbl:pipeline_steps] — number 25, one per numbered step. The Model Context Protocol surface — which exposes GNN's capabilities to external agents and tools — provides 164 tools across 36 modules. The pipeline itself runs as 25 steps (0–24), and 7 figure artifacts from the rendering of figures, models, and reports are committed under `output/`, of which 7 are the manuscript's own. ## Claim Discipline @@ -477,7 +477,7 @@ uv run --frozen python scripts/pipeline/stage_03_render.py \ The render needs a LaTeX installation providing the packages listed in `manuscript/preamble.md` plus `seqsplit`; the template guards `seqsplit` with `\IfFileExists`, so a missing copy degrades rather than failing the build. -Because the variables file is regenerated before rendering, the counts in the rendered PDF track the repository state at the commit recorded in `output/data/manuscript_variables.json` (690f8c5): a code change that alters, for example, the test inventory (656 test files, 6776 test functions) propagates into the prose on the next regeneration without any manual editing. +Because the variables file is regenerated before rendering, the counts in the rendered PDF track the repository state at the commit recorded in `output/data/manuscript_variables.json` (4ed8b33): a code change that alters, for example, the test inventory (658 test files, 6790 test functions) propagates into the prose on the next regeneration without any manual editing. ## Reproducibility Contract @@ -525,7 +525,7 @@ The next major release pushes toward bounded autonomy, and it is gated behind th This supplement records the top-level source surfaces a reader or reviewer should inspect before turning any prose in the main manuscript into a verifiable claim. Each surface below is authored material under version control; the generated artifacts it produces are described separately so that the boundary between hand-written source and reproducible output stays explicit. -The `src/` tree is the executable core of GNN. It is organized into 44 Python packages spanning 765 source files and roughly 225574 lines of code. Alongside these packages sit the 25 top-level step modules (0–24) that implement the numbered pipeline as thin orchestrators delegating into the packages, each module owning one stage of the progression from a parsed GNN text model through visualization, type checking, code export, and executable cognitive simulation. A claim about declared pipeline behavior should identify its source implementation; a claim about actual execution additionally requires a source-bound command and result receipt. +The `src/` tree is the executable core of GNN. It is organized into 44 Python packages spanning 766 source files and roughly 225800 lines of code. Alongside these packages sit the 25 top-level step modules (0–24) that implement the numbered pipeline as thin orchestrators delegating into the packages, each module owning one stage of the progression from a parsed GNN text model through visualization, type checking, code export, and executable cognitive simulation. A claim about declared pipeline behavior should identify its source implementation; a claim about actual execution additionally requires a source-bound command and result receipt. The `input/` tree holds the model corpora that exercise the pipeline. Its `gnn_files/` subtree contains 12 corpus directories organized by model family, together totaling 38 example files — 29 discrete-state specifications and 9 continuous linear-Gaussian specifications, the partition the manuscript's model-kind accounting uses ([@sec:model_kinds]). The cold-start index `input/gnn_files/INDEX.md` catalogues every exemplar with its kind and task folder, and the `recursive/` directory is reserved for bounded `--autonomous` proposal-loop runs, holding no committed models. Every model file under `input/` lives in that subtree, so the 38-file count covers the whole tree. A `model_family_manifest.json` enumerates the 9 registered families and their target directories. This manifest is the authoritative registry that downstream steps and the manuscript variable producer read when they report family counts and cross-framework coverage; it should be consulted directly rather than inferred from directory listings. diff --git a/output/pdf/_combined_manuscript.tex b/output/pdf/_combined_manuscript.tex index 9d471fc0f..60d7b014f 100644 --- a/output/pdf/_combined_manuscript.tex +++ b/output/pdf/_combined_manuscript.tex @@ -300,7 +300,7 @@ \section{Abstract}\label{sec:abstract} \centering \includegraphics[width=1\linewidth,height=0.5\textheight,keepaspectratio,alt={The GeneralizedNotationNotation (GNN) pipeline at a glance, generated from -the producer's token map at commit 690f8c5. A plain-text specification +the producer's token map at commit 4ed8b33. A plain-text specification --- the StateSpaceBlock declaring the model's tensors, A, B, C, D, and E for the discrete categorical kind of eq.~ through eq.~ and its own matrix vocabulary for the other model kinds --- is parsed (Step 3), @@ -321,7 +321,7 @@ \section{Abstract}\label{sec:abstract} execution adapters, and 164 Model Context Protocol tools.}]{../figures/gnn_graphical_abstract.png} \caption{The GeneralizedNotationNotation (GNN) pipeline at a glance, -generated from the producer's token map at commit 690f8c5. A plain-text +generated from the producer's token map at commit 4ed8b33. A plain-text specification --- the \protect\texttt{StateSpaceBlock} declaring the model's tensors, \protect\texttt{A}, \protect\texttt{B}, \protect\texttt{C}, \protect\texttt{D}, and \protect\texttt{E} for the @@ -1026,7 +1026,7 @@ \subsection{The Processing Pipeline}\label{the-processing-pipeline} mirrors the live step registry. Parsing (Step 3) precedes many later steps. Optional artifact enrichments are documented separately and are not shown as required edges. The figure is generated from -src/gnn/STEP\_INDEX.md at commit 690f8c5; its footer restates the step +src/gnn/STEP\_INDEX.md at commit 4ed8b33; its footer restates the step count, the number of required prerequisites, and the layout rule.}]{../figures/gnn_pipeline_dag.png} \caption{The 25-step GNN processing pipeline as a directed acyclic @@ -1038,7 +1038,7 @@ \subsection{The Processing Pipeline}\label{the-processing-pipeline} mirrors the live step registry. Parsing (Step 3) precedes many later steps. Optional artifact enrichments are documented separately and are not shown as required edges. The figure is generated from -\protect\breaktt{src/gnn/STEP\_INDEX.md} at commit 690f8c5; its footer +\protect\breaktt{src/gnn/STEP\_INDEX.md} at commit 4ed8b33; its footer restates the step count, the number of required prerequisites, and the layout rule.}\label{fig:pipeline} \end{figure} @@ -1165,7 +1165,7 @@ \subsection{The Triple Play}\label{the-triple-play} summarizes 12 of 12 registered render targets with implemented Step 12 execution adapters and previews three by name; execution depends on runtime readiness, admitted model kind, and a successful source-bound -run receipt. Counts are producer tokens at commit 690f8c5, and the panel +run receipt. Counts are producer tokens at commit 4ed8b33, and the panel is generated by scripts/manuscript\_fig\_triple\_play.py. Take-away: a model is authored once and then read, seen, and run from that single source.}]{../figures/gnn_triple_play.png} @@ -1179,7 +1179,7 @@ \subsection{The Triple Play}\label{the-triple-play} targets with implemented Step 12 execution adapters and previews three by name; execution depends on runtime readiness, admitted model kind, and a successful source-bound run receipt. Counts are producer tokens at -commit 690f8c5, and the panel is generated by +commit 4ed8b33, and the panel is generated by \protect\breaktt{scripts/manuscript\_fig\_triple\_play.py}. Take-away: a model is authored once and then read, seen, and run from that single source.}\label{fig:triple_play} @@ -1402,7 +1402,7 @@ \subsection{Rendering to Multiple the maintained set, so it is never profiled; a red Render-Subdir cell would mean a registry entry with no subdirectory. The table is read directly from src/gnn/render/framework\_registry.py and the model-family -manifest at commit 690f8c5, and its footer summarizes the row counts. +manifest at commit 4ed8b33, and its footer summarizes the row counts. Take-away: registration, render presence, and profiling status are three different claims, and the table keeps them apart.}]{../figures/gnn_backend_capability_matrix.png} @@ -1416,7 +1416,7 @@ \subsection{Rendering to Multiple never profiled; a red Render-Subdir cell would mean a registry entry with no subdirectory. The table is read directly from \protect\breaktt{src/gnn/render/framework\_registry.py} and the -model-family manifest at commit 690f8c5, and its footer summarizes the +model-family manifest at commit 4ed8b33, and its footer summarizes the row counts. Take-away: registration, render presence, and profiling status are three different claims, and the table keeps them apart.}\label{fig:backend_matrix} @@ -1621,7 +1621,7 @@ \subsection{Reproducibility and Every count reported in this manuscript is emitted by the producer \breaktt{scripts/z\_generate\_manuscript\_variables.py} from the -repository state at commit 690f8c5, not typed by hand. The producer +repository state at commit 4ed8b33, not typed by hand. The producer reads that commit's source surfaces directly --- the step modules, the source tree, the model-family manifest, and the framework registry --- together with the project's maintained ledgers, notably the Model @@ -1652,7 +1652,7 @@ \section{Artifacts and Evidence}\label{sec:artifacts_evidence} This section reports what the project has actually produced and how each quantitative claim is grounded. Every number below is substituted at render time from the deterministic producer, which reads the repository -state at commit 690f8c5, so each figure here is regenerated from the +state at commit 4ed8b33, so each figure here is regenerated from the artifacts it describes rather than transcribed. \subsection{Model-Family Coverage}\label{model-family-coverage} @@ -1773,7 +1773,7 @@ \subsection{Model-Family Coverage}\label{model-family-coverage} continuous, discrete, and gridworld declare several. Read it as declared intent, not as profiled outcomes: the gates described below supply the outcomes. The matrix is generated from the two registries at commit -690f8c5.}]{../figures/gnn_family_framework_matrix.png} +4ed8b33.}]{../figures/gnn_family_framework_matrix.png} \caption{Model-family coverage across the registered rendering backends: one row per family from \protect\breaktt{input/model\_family\_manifest.json}, one column per @@ -1785,7 +1785,7 @@ \subsection{Model-Family Coverage}\label{model-family-coverage} gridworld declare several. Read it as declared intent, not as profiled outcomes: the gates described below supply the outcomes. The matrix is generated from the two registries at commit -690f8c5.}\label{fig:family_matrix} +4ed8b33.}\label{fig:family_matrix} \end{figure} These families are not illustrative prose: they are the inputs over @@ -1857,7 +1857,7 @@ \subsection{Repository Scale}\label{repository-scale} The repository's scale is itself evidence of the surface that the gates and pipeline cover, and it is reported in fig.~\ref{fig:repo_metrics} -directly from the tracked files at commit 690f8c5. +directly from the tracked files at commit 4ed8b33. \begin{figure} \centering @@ -1867,7 +1867,7 @@ \subsection{Repository Scale}\label{repository-scale} tools, source packages, test files, example models, and documentation files. Every bar is annotated with its exact value, and every value is a producer token read from output/data/manuscript\_variables.json at -commit 690f8c5 --- the same token map that substitutes the prose counts. +commit 4ed8b33 --- the same token map that substitutes the prose counts. The figure-freshness suite binds the committed image digest and its recorded producer inputs and counts; semantic content and visual correctness require their own review. The two backend bars are @@ -1883,7 +1883,7 @@ \subsection{Repository Scale}\label{repository-scale} models, and documentation files. Every bar is annotated with its exact value, and every value is a producer token read from \protect\breaktt{output/data/manuscript\_variables.json} at commit -690f8c5 --- the same token map that substitutes the prose counts. The +4ed8b33 --- the same token map that substitutes the prose counts. The figure-freshness suite binds the committed image digest and its recorded producer inputs and counts; semantic content and visual correctness require their own review. The two backend bars are deliberately @@ -1895,8 +1895,8 @@ \subsection{Repository Scale}\label{repository-scale} as a quality measure.}\label{fig:repo_metrics} \end{figure} -The test suite comprises 656 test files containing 6776 test functions, -exercising a source base of 765 Python files across 44 packages (225574 +The test suite comprises 658 test files containing 6790 test functions, +exercising a source base of 766 Python files across 44 packages (225800 lines of source). The pipeline's step modules --- the thin orchestrators named in tbl.~\ref{tbl:pipeline_steps} --- number 25, one per numbered step. The Model Context Protocol surface --- which exposes GNN's @@ -2103,8 +2103,8 @@ \subsection{Manuscript Because the variables file is regenerated before rendering, the counts in the rendered PDF track the repository state at the commit recorded in -\breaktt{output/data/manuscript\_variables.json} (690f8c5): a code change -that alters, for example, the test inventory (656 test files, 6776 test +\breaktt{output/data/manuscript\_variables.json} (4ed8b33): a code change +that alters, for example, the test inventory (658 test files, 6790 test functions) propagates into the prose on the next regeneration without any manual editing. @@ -2295,7 +2295,7 @@ \section{Supplemental Source Surface}\label{sec:source_surface} reproducible output stays explicit. The \texttt{src/} tree is the executable core of GNN. It is organized -into 44 Python packages spanning 765 source files and roughly 225574 +into 44 Python packages spanning 766 source files and roughly 225800 lines of code. Alongside these packages sit the 25 top-level step modules (0--24) that implement the numbered pipeline as thin orchestrators delegating into the packages, each module owning one stage diff --git a/src/gnn/render/cpomdp/AGENTS.md b/src/gnn/render/cpomdp/AGENTS.md index 32f0f438f..18b28b081 100644 --- a/src/gnn/render/cpomdp/AGENTS.md +++ b/src/gnn/render/cpomdp/AGENTS.md @@ -11,11 +11,12 @@ ## Files - `cpomdp_renderer.py` refuses non-continuous specs, parses the spec with `render.continuous_common.extract_continuous_spec`, validates the options and writes the script atomically. +- `finite_controls.py` validates the E3-FA1 source declaration, ordered IDs, units and explicit control maps without importing inference libraries. - `script_template.py` holds the emitted program. It reuses `continuous_common.literal_block` for the matrix literals. It does not reuse `continuous_script`, whose template hard-codes GNN's proportional controller. ## Design notes -- The emitted `LinearGaussianModel` gets `control = I_n` when a goal is declared, because the GNN contract adds `u_t` straight to the state. +- The compass-rose `LinearGaussianModel` uses `control = I_n`. E3-FA1 source declarations provide an explicit state-by-control matrix; recorded `controls` remain state increments and `control_inputs` preserve control coordinates. - The prior is over `x_1`. Step 1 therefore runs through a `KalmanBackend` with identity dynamics and zero process noise, which folds the reading in without a predict step. - Policies come from `cpomdp.enumeration.EnumeratedEfeSearch` over a versioned compass-rose `FiniteActionSet`. One `vmap` of `policy_efe` scores every policy per step and returns G with its pragmatic and epistemic parts, so the chosen policy is not rolled out twice. `EFESelector` is not used because it only searches 1-D actions. - The per-step search cost is reported as `cost_per_cycle` (`|A|^H * H` step evaluations). `parity` mode pays it too, because it records the scores it does not act on. diff --git a/src/gnn/render/cpomdp/README.md b/src/gnn/render/cpomdp/README.md index 4345d8a7b..6919d986b 100644 --- a/src/gnn/render/cpomdp/README.md +++ b/src/gnn/render/cpomdp/README.md @@ -36,3 +36,8 @@ Direct Python rendering uses the same typed option admission as the shared CLI, REST and MCP surfaces. Unknown keys, explicit nulls, numeric strings and noncanonical control modes refuse before replacing the destination. Search admission also requires an actual Boolean control flag. + +Source-authored finite actions and asymmetric control matrices are available +through [E3-FA1](../../../../docs/gnn/implementations/cpomdp.md#source-declared-finite-controls-e3-fa1). +The declaration includes ordered IDs, units and a version. It requires EFE mode +and retains complete policy identity and per-policy pragmatic/epistemic scores. diff --git a/src/gnn/render/cpomdp/SPEC.md b/src/gnn/render/cpomdp/SPEC.md index a5ea8de56..ae3c1f3e0 100644 --- a/src/gnn/render/cpomdp/SPEC.md +++ b/src/gnn/render/cpomdp/SPEC.md @@ -7,7 +7,7 @@ ## Inputs - `gnn_spec` is the continuous spec (`model_kind == "continuous"`). It carries `F`, `H`, `Q`, `R`, `prior_mean` and `prior_cov`, optionally `goal_mean` with `control_gain`, and `model_parameters` with `num_timesteps` and `random_seed`. -- `options` are listed in [README.md](README.md). An invalid value fails the render with a message naming the option. Booleans, fractional horizons and nonfinite values are rejected. Admission ceilings can be lowered using `max_policies`, `max_step_evaluations` and `max_estimated_bytes`; they cannot be raised above the maintained limits. Unknown keys are ignored. +- `options` are listed in [README.md](README.md). An invalid value fails the render with a message naming the option. Booleans, fractional horizons and nonfinite values are rejected. Admission ceilings can be lowered using `max_policies`, `max_step_evaluations` and `max_estimated_bytes`; they cannot be raised above the maintained limits. Unknown keys are rejected. ## Outputs @@ -25,3 +25,14 @@ cpomdp adds `efe_history` (one row of `|A|^H` values per step, empty when passiv The versioned action set has nine actions for two control dimensions, otherwise `2p+1`. Passive models perform no search. Requested searches are never silently reduced. The exact conservative estimate is recorded under `admission`, together with policy and step counts. `measured_resources` records peak process RSS and elapsed time. `source_identity` binds the validated numeric contract; pipeline runs additionally bind the original relative path, source SHA and model ID. `generated_script_sha256`, `cpomdp_version`, `jax_version`, `action_set` and `selected_policy` retain execution identity and decisions. These hashes establish identity, not extraction correctness or universal equivalence. + +## Source controls + +The optional `initialparameterization.cpomdp_control` mapping follows the +[E3-FA1 contract](../../../../docs/gnn/implementations/cpomdp.md#source-declared-finite-controls-e3-fa1). +It admits only flat continuous EFE models, preserves ordered action identities +and explicit asymmetric control maps, and adds complete per-policy component +receipts. `controls` retain state increments while `control_inputs` retain +authored control coordinates. Other backends refuse this declaration. +The total evaluation ceiling is 262,144 across all timesteps; passive models +retain zero search cost. The numeric model digest includes all control metadata. diff --git a/src/gnn/render/cpomdp/admission.py b/src/gnn/render/cpomdp/admission.py index e5066c61f..b2ea74745 100644 --- a/src/gnn/render/cpomdp/admission.py +++ b/src/gnn/render/cpomdp/admission.py @@ -4,6 +4,7 @@ MAX_POLICIES = 4096 MAX_STEP_EVALUATIONS = 32768 +MAX_TOTAL_STEP_EVALUATIONS = 262144 MAX_ESTIMATED_BYTES = 128 * 1024 * 1024 @@ -23,6 +24,7 @@ def admit_search( max_policies: int = MAX_POLICIES, max_step_evaluations: int = MAX_STEP_EVALUATIONS, max_estimated_bytes: int = MAX_ESTIMATED_BYTES, + action_count: int | None = None, ) -> dict: """Validate and estimate search allocation without computing an unbounded power. @@ -49,7 +51,13 @@ def admit_search( raise ValueError( f"{name} exceeds the maintained admission ceiling {ceiling}" ) - action_count = 9 if p == 2 else 2 * p + 1 + declared_actions = action_count is not None + if action_count is None: + action_count = 9 if p == 2 else 2 * p + 1 + else: + _positive_int(action_count, "action_count") + if action_count < 2: + raise ValueError("action_count must be at least two") policies = 0 if controlled: policies = 1 @@ -60,20 +68,27 @@ def admit_search( step_evaluations = policies * horizon if step_evaluations > max_step_evaluations: raise ValueError("cpomdp search exceeds max_step_evaluations") + total_step_evaluations = step_evaluations * timesteps + if total_step_evaluations > MAX_TOTAL_STEP_EVALUATIONS: + raise ValueError("cpomdp search exceeds max_total_step_evaluations") estimate = 64 * policies * ( horizon * (p + 1) + n * n + m * m + 4 * (n + m) + 16 ) + 64 * timesteps * (policies + n * n + 3 * n + m + 8) + if declared_actions: + estimate += 64 * (action_count * p + n * p) if estimate > max_estimated_bytes: raise ValueError("cpomdp estimated allocation exceeds max_estimated_bytes") return { "policy_count": policies, "step_evaluations_per_cycle": step_evaluations, + "total_step_evaluations": total_step_evaluations, "action_count": action_count if controlled else 0, "estimated_bytes": estimate, "estimate_kind": "conservative algorithm allocation estimate; excludes runtime/JIT overhead", "limits": { "policies": max_policies, "step_evaluations": max_step_evaluations, + "total_step_evaluations": MAX_TOTAL_STEP_EVALUATIONS, "estimated_bytes": max_estimated_bytes, }, } diff --git a/src/gnn/render/cpomdp/cpomdp_renderer.py b/src/gnn/render/cpomdp/cpomdp_renderer.py index c48f8af7d..99da4d374 100644 --- a/src/gnn/render/cpomdp/cpomdp_renderer.py +++ b/src/gnn/render/cpomdp/cpomdp_renderer.py @@ -55,12 +55,56 @@ def render_gnn_to_cpomdp( return False, UNSUPPORTED_MESSAGE, [] try: + from gnn.render.cpomdp.finite_controls import finite_controls + from gnn.render.pomdp_contract import ( + detect_model_kinds, + unsupported_composition_reason, + ) + + kinds = detect_model_kinds(gnn_spec) + if len(kinds) > 1: + return False, unsupported_composition_reason(kinds), [] spec = extract_continuous_spec(gnn_spec) resolved = resolve_render_options(options) + initial = gnn_spec.get("initialparameterization") or gnn_spec.get( + "initial_parameterization" + ) + declarations = [ + mapping["cpomdp_control"] + for mapping in ( + gnn_spec.get("initialparameterization"), + gnn_spec.get("initial_parameterization"), + ) + if isinstance(mapping, dict) and "cpomdp_control" in mapping + ] + if declarations: + if not isinstance(initial, dict) or "cpomdp_control" not in initial: + raise ValueError( + "cpomdp_control declarations differ between parameter mappings" + ) + control = finite_controls(initial["cpomdp_control"], spec.n) + if any( + finite_controls(declared, spec.n) != control + for declared in declarations + ): + raise ValueError( + "cpomdp_control declarations differ between parameter mappings" + ) + if not spec.has_control: + raise ValueError("cpomdp_control requires goal_mean and control_gain") + if resolved["control_mode"] != "efe" or ( + options and "action_scale" in options + ): + raise ValueError( + "cpomdp_control requires efe mode and no action_scale override" + ) + resolved["finite_controls"] = control contract = { key: value.tolist() if hasattr(value, "tolist") else value for key, value in vars(spec).items() } + if "finite_controls" in resolved: + contract["cpomdp_control"] = resolved["finite_controls"] resolved["source_identity"] = { "model_contract_sha256": hashlib.sha256( json.dumps(contract, sort_keys=True).encode() diff --git a/src/gnn/render/cpomdp/finite_controls.py b/src/gnn/render/cpomdp/finite_controls.py new file mode 100644 index 000000000..ea3d067d3 --- /dev/null +++ b/src/gnn/render/cpomdp/finite_controls.py @@ -0,0 +1,99 @@ +"""Strict source-declared finite controls; no inference dependency imports.""" + +from __future__ import annotations + +import math +from numbers import Real +from typing import Any + +from .admission import MAX_ESTIMATED_BYTES, MAX_POLICIES + +FINITE_CONTROL_CONTRACT = "gnn-cpomdp-finite-actions-v1" + + +def finite_controls(raw: object, n: int) -> dict[str, Any]: + """Validate ordered actions and an explicit state-by-control linear map. + + Duplicate vectors retain distinct action IDs and authored tie order. Units + are declarations, not a dimensional-analysis or extraction proof. + """ + keys = { + "contract", + "version", + "action_ids", + "control_units", + "state_units", + "actions", + "control_matrix", + } + if not isinstance(raw, dict) or set(raw) != keys: + raise ValueError(f"cpomdp_control requires exactly {sorted(keys)}") + if raw["contract"] != FINITE_CONTROL_CONTRACT: + raise ValueError("unsupported cpomdp_control contract") + + def label(value: object) -> str: + if not isinstance(value, str) or not value.strip() or len(value) > 128: + raise ValueError( + "cpomdp_control labels must be nonempty strings of at most 128 characters" + ) + return value + + version = label(raw["version"]) + actions, matrix = raw["actions"], raw["control_matrix"] + if not isinstance(actions, (list, tuple)) or not 2 <= len(actions) <= MAX_POLICIES: + raise ValueError("cpomdp_control requires 2..4096 ordered actions") + if not isinstance(matrix, (list, tuple)) or len(matrix) != n: + raise ValueError("cpomdp_control control_matrix must have n state rows") + if not isinstance(matrix[0], (list, tuple)) or not matrix[0]: + raise ValueError("cpomdp_control control_matrix needs a nonempty control axis") + p = len(matrix[0]) + if 64 * (len(actions) * p + n * p) > MAX_ESTIMATED_BYTES: + raise ValueError( + "cpomdp_control declared allocation exceeds max_estimated_bytes" + ) + + def labels(value: object, count: int) -> list[str]: + if not isinstance(value, (list, tuple)) or len(value) != count: + raise ValueError( + "cpomdp_control label dimensions must match their declared axes" + ) + return [label(v) for v in value] + + ids = labels(raw["action_ids"], len(actions)) + if len(set(ids)) != len(ids): + raise ValueError("cpomdp_control action_ids must be unique") + + def rows(value: Any) -> list[list[float]]: + result = [] + for row in value: + if not isinstance(row, (list, tuple)) or len(row) != p: + raise ValueError("cpomdp_control rows must match the control dimension") + converted = [] + for v in row: + if isinstance(v, bool) or not isinstance(v, Real): + raise ValueError( + "cpomdp_control values must be finite nonboolean numbers" + ) + try: + number = float(v) + except OverflowError as exc: + raise ValueError( + "cpomdp_control values must be finite nonboolean numbers" + ) from exc + if not math.isfinite(number): + raise ValueError( + "cpomdp_control values must be finite nonboolean numbers" + ) + converted.append(number) + result.append(converted) + return result + + return { + "contract": FINITE_CONTROL_CONTRACT, + "version": version, + "action_ids": ids, + "control_units": labels(raw["control_units"], p), + "state_units": labels(raw["state_units"], n), + "actions": rows(actions), + "control_matrix": rows(matrix), + } diff --git a/src/gnn/render/cpomdp/script_template.py b/src/gnn/render/cpomdp/script_template.py index 6bf7129df..a91502e90 100644 --- a/src/gnn/render/cpomdp/script_template.py +++ b/src/gnn/render/cpomdp/script_template.py @@ -13,8 +13,8 @@ x_t = F x_{t-1} + u_{t-1} + N(0, Q) y_t = H x_t + N(0, R) -The control input adds straight to the state, so cpomdp's ``control`` -is the identity. The prior is over ``x_1``, so the first reading is folded in +The compass-rose control input adds straight to the state. Source-declared finite +controls instead supply an explicit state-by-control matrix. The prior is over ``x_1``, so the first reading is folded in without a predict step. With a declared ``goal_mean``/``control_gain`` there are two control modes. ``efe`` applies the first action of the policy with the lowest expected free energy. ``parity`` applies GNN's @@ -38,6 +38,7 @@ MAX_ESTIMATED_BYTES, MAX_POLICIES, MAX_STEP_EVALUATIONS, + MAX_TOTAL_STEP_EVALUATIONS, _positive_int, admit_search, ) @@ -163,6 +164,13 @@ def is_psd(cov): _MODEL = ''' +def selected_policy_ids(index): + """Decode the library's Cartesian-product policy order without deduplication.""" + ids = FINITE_CONTROLS["action_ids"] + count = len(ids) + return [ids[(index // (count ** (HORIZON - 1 - k))) % count] for k in range(HORIZON)] + + def compass_rose(control_dim, scale): """Build the declared, versioned action set the policy search enumerates. @@ -202,7 +210,7 @@ def build_model(controlled): dynamics_noise=arr(Q_RAW), # Q sensor_noise=arr(R_RAW), # R prior=Belief(arr(PRIOR_MEAN_RAW), arr(PRIOR_COV_RAW)), - control=jnp.eye(n_states) if controlled else None, + control=arr(CONTROL_MATRIX_RAW) if controlled else None, ) @@ -232,7 +240,8 @@ def build_planner(model, goal): The scorer returns each policy's G and its pragmatic and epistemic parts, so the chosen policy is never rolled out a second time. """ - action_set = compass_rose(model.n_controls, ACTION_SCALE) + action_set = (FiniteActionSet(FINITE_CONTROLS["actions"], version=ACTION_SET_VERSION) + if FINITE_CONTROLS else compass_rose(model.n_controls, ACTION_SCALE)) search = EnumeratedEfeSearch(model, action_set, horizon=HORIZON) preference = Preference( model.sensor_model @ goal, @@ -255,14 +264,16 @@ def run_simulation(): """Simulate the GNN process, filter it with cpomdp and write the results.""" start = time.time() passive = GOAL_MEAN_RAW is None or CONTROL_GAIN is None - admission = admit_search(len(F_RAW), len(H_RAW), len(F_RAW), NUM_TIMESTEPS, HORIZON, not passive, MAX_POLICIES, MAX_STEP_EVALUATIONS, MAX_ESTIMATED_BYTES) + admission = admit_search(len(F_RAW), len(H_RAW), CONTROL_DIM, NUM_TIMESTEPS, HORIZON, not passive, MAX_POLICIES, MAX_STEP_EVALUATIONS, MAX_ESTIMATED_BYTES, len(FINITE_CONTROLS["actions"]) if FINITE_CONTROLS else None) model = build_model(controlled=not passive) backend = KalmanBackend(model) n_states, n_steps = model.n_states, NUM_TIMESTEPS - no_action = jnp.zeros(n_states) + no_action = jnp.zeros(CONTROL_DIM) + control_matrix = arr(CONTROL_MATRIX_RAW) true_states, observations, beliefs, covs, controls = [], [], [], [], [] efe_history, epistemic, pragmatic, selected = [], [], [], [] + control_inputs, all_epistemic, all_pragmatic = [], [], [] if GOAL_MEAN_RAW is None or CONTROL_GAIN is None: control_mode = "passive" @@ -281,7 +292,7 @@ def choose(belief): "n_policies": search.n_policies, "cost_per_cycle": search.cost_per_cycle, "search_certificate": {"expected": search.certificate.expected, "visited": search.certificate.visited, "claim": "exhaustive enumeration of the declared finite action sequences"}, - "action_set": to_list(compass_rose(model.n_controls, ACTION_SCALE).actions), + "action_set": (FINITE_CONTROLS["actions"] if FINITE_CONTROLS else to_list(compass_rose(model.n_controls, ACTION_SCALE).actions)), } def choose(belief): @@ -291,6 +302,8 @@ def choose(belief): raise ValueError("All enumerated cpomdp policy scores are invalid") best = int(jnp.argmin(jnp.where(jnp.isfinite(g), g, jnp.inf))) efe_history.append(to_list(g)) + all_epistemic.append(to_list(parts["epistemic"])) + all_pragmatic.append(to_list(parts["pragmatic"])) selected.append(best) epistemic.append(float(parts["epistemic"][best])) pragmatic.append(float(parts["pragmatic"][best])) @@ -304,7 +317,8 @@ def record(state, reading, belief, action): (observations, reading), (beliefs, belief.mean), (covs, belief.cov), - (controls, action), + (controls, control_matrix @ action), + (control_inputs, action), ): buf.append(to_list(val)) @@ -315,8 +329,8 @@ def record(state, reading, belief, action): record(state, reading, belief, action) for _step in range(1, n_steps): - process_noise = mvn_sample(no_action, model.dynamics_noise) - state = model.dynamics @ state + action + process_noise + process_noise = mvn_sample(jnp.zeros(n_states), model.dynamics_noise) + state = model.dynamics @ state + control_matrix @ action + process_noise reading = sample_observation(model, state) belief = backend.infer_states(reading, belief, None if passive else action) action = choose(belief) @@ -344,6 +358,12 @@ def record(state, reading, belief, action): "true_states_continuous": true_states, "observations_continuous": observations, "controls": controls, + "control_inputs": control_inputs, + "control_matrix": CONTROL_MATRIX_RAW, + "finite_control_contract": FINITE_CONTROLS, + "action_set_version": ACTION_SET_VERSION, + "policy_action_ids": [selected_policy_ids(i) for i in selected] if FINITE_CONTROLS else [], + "policy_score_components": {"pragmatic": all_pragmatic, "epistemic": all_epistemic}, "control_mode": control_mode, "rmse_vs_true": rmse, # Discrete-schema slots stay empty: nothing categorical is defined here. @@ -361,7 +381,7 @@ def record(state, reading, belief, action): "measured_resources": measured_resources(start), "selected_policy": [to_list(search.policies[i]) for i in selected] if not passive else [], "horizon": HORIZON, - "action_scale": ACTION_SCALE, + "action_scale": None if FINITE_CONTROLS else ACTION_SCALE, **search_stats, **FRAMEWORK_VERSION, } @@ -397,6 +417,13 @@ def generate_cpomdp_script(spec: ContinuousSpec, opts: Mapping[str, Any]) -> str The script source, already in ruff's format. """ lits = literal_block(spec) + finite = opts.get("finite_controls") + matrix = ( + finite["control_matrix"] + if finite + else [[float(i == j) for j in range(spec.n)] for i in range(spec.n)] + ) + p = len(matrix[0]) if not spec.has_control: control_desc = "passive tracker (no goal_mean/control_gain)" elif opts["control_mode"] == "parity": @@ -408,7 +435,7 @@ def generate_cpomdp_script(spec: ContinuousSpec, opts: Mapping[str, Any]) -> str Generated by the GNN pipeline's cpomdp renderer. GNN continuous contract: x_1 ~ N(prior_mean, prior_cov) - x_t = F x_(t-1) + u_(t-1) + N(0, Q) ({spec.n}-dim latent state) + x_t = F x_(t-1) + M a_(t-1) + N(0, Q) ({spec.n}-dim latent state) y_t = H x_t + N(0, R) ({spec.m}-dim observation) Control: {control_desc}. Filter: cpomdp KalmanBackend. Policies: every sequence of a declared action set. @@ -431,10 +458,14 @@ def generate_cpomdp_script(spec: ContinuousSpec, opts: Mapping[str, Any]) -> str ACTION_SCALE = {opts["action_scale"]!r} HORIZON = {opts["horizon"]} GOAL_PRECISION = {opts["goal_precision"]!r} -ACTION_SET_VERSION = {json.dumps(ACTION_SET_VERSION)} +ACTION_SET_VERSION = {json.dumps(finite["version"] if finite else ACTION_SET_VERSION)} +FINITE_CONTROLS = {finite!r} +CONTROL_MATRIX_RAW = {matrix!r} +CONTROL_DIM = {p} SOURCE_IDENTITY = {opts.get("source_identity", {})!r} MAX_POLICIES = {opts["max_policies"]} MAX_STEP_EVALUATIONS = {opts["max_step_evaluations"]} +MAX_TOTAL_STEP_EVALUATIONS = {MAX_TOTAL_STEP_EVALUATIONS} MAX_ESTIMATED_BYTES = {opts["max_estimated_bytes"]} F_RAW = {lits["F"]} @@ -449,13 +480,14 @@ def generate_cpomdp_script(spec: ContinuousSpec, opts: Mapping[str, Any]) -> str admit_search( spec.n, spec.m, - spec.n, + p, spec.num_timesteps, opts["horizon"], spec.has_control, opts["max_policies"], opts["max_step_evaluations"], opts["max_estimated_bytes"], + len(finite["actions"]) if finite else None, ) admission_source = ( inspect.getsource(_positive_int) + "\n" + inspect.getsource(admit_search) diff --git a/src/gnn/render/execution_contracts.py b/src/gnn/render/execution_contracts.py index ec0e43dfc..84244d050 100644 --- a/src/gnn/render/execution_contracts.py +++ b/src/gnn/render/execution_contracts.py @@ -23,10 +23,21 @@ def execution_contract(spec: object) -> str | None: return str(value) if value is not None else None -def unsupported_contract(spec: dict[str, Any], framework: str) -> str | None: +def unsupported_contract(spec: object, framework: str) -> str | None: """Fail closed for unknown versions and backends lacking full semantics.""" from gnn.render.framework_registry import FRAMEWORK_REGISTRY + if not isinstance(spec, dict): + return None + declared_controls = any( + isinstance(initial, dict) and "cpomdp_control" in initial + for initial in ( + spec.get("initialparameterization"), + spec.get("initial_parameterization"), + ) + ) + if declared_controls and framework != "cpomdp": + return f"unsupported-cpomdp-control: {framework} cannot execute source-declared finite controls" contract = execution_contract(spec) supported = FRAMEWORK_REGISTRY.get(framework, {}).get("execution_contracts", ()) if contract is not None and ( diff --git a/src/gnn/render/framework_registry.py b/src/gnn/render/framework_registry.py index 23242061b..cb3c77f49 100644 --- a/src/gnn/render/framework_registry.py +++ b/src/gnn/render/framework_registry.py @@ -227,7 +227,11 @@ "description": "Experimental released continuous active-inference backend", "language": "Python", "file_extension": ".py", - "supported_features": ["Kalman Filtering", "Finite Policy EFE Search"], + "supported_features": [ + "Kalman Filtering", + "Finite Policy EFE Search", + "Source-Declared Finite Controls", + ], "function": "render_gnn_to_cpomdp", "output_format": "python", "pomdp_compatible": True, diff --git a/src/gnn/render/jax/jax_renderer.py b/src/gnn/render/jax/jax_renderer.py index def5cc16b..03e3675f0 100644 --- a/src/gnn/render/jax/jax_renderer.py +++ b/src/gnn/render/jax/jax_renderer.py @@ -68,8 +68,12 @@ def render_gnn_to_jax( from gnn.render.execution_contracts import ( execution_contract, generate_contract_script, + unsupported_contract, ) + refusal = unsupported_contract(gnn_spec, "jax") + if refusal: + return False, refusal, [] if execution_contract(gnn_spec) is not None: return _render_to_path( generate_contract_script, diff --git a/src/gnn/render/numpyro/README.md b/src/gnn/render/numpyro/README.md index b162b5c29..fa6e5a852 100644 --- a/src/gnn/render/numpyro/README.md +++ b/src/gnn/render/numpyro/README.md @@ -24,3 +24,13 @@ The rendered artifact is a single `.py` file. When executed, it writes `simulati - render-time: `numpy` - generated script run-time: `jax`, `jaxlib`, `numpyro`, `numpy` + +## Discrete filtering contract + +The first observation conditions the authored initial prior. Each later prior +is the previous posterior propagated by the previous selected action's transition +matrix, before conditioning on the new observation. The recorded beliefs are +posteriors. Zero or nonfinite predictive evidence raises an error; invalid result +validation is persisted and then raises an error, so an invalid trace cannot +complete successfully. This is categorical filtering and action scoring, not a +claim of equivalence to other backends' policy objectives. diff --git a/src/gnn/render/numpyro/numpyro_renderer.py b/src/gnn/render/numpyro/numpyro_renderer.py index a36b2425f..d6352104e 100644 --- a/src/gnn/render/numpyro/numpyro_renderer.py +++ b/src/gnn/render/numpyro/numpyro_renderer.py @@ -245,10 +245,15 @@ def run_simulation(seed: int = 42): observation = int(dist.Categorical(probs=obs_probs).sample(subkey)) # 2. Bayesian belief update + # Predict with the action that produced this observation; at t=0 + # the authored initial prior applies without a transition. + prior = beliefs if t == 0 else B[:, :, actions_history[-1]] @ beliefs likelihood = A[observation, :] - posterior = likelihood * beliefs - posterior = posterior / (jnp.sum(posterior) + 1e-16) - beliefs = posterior + posterior = likelihood * prior + evidence = jnp.sum(posterior) + if not np.isfinite(float(evidence)) or float(evidence) <= 0.0: + raise RuntimeError("Observation has zero or nonfinite predictive probability") + beliefs = posterior / evidence # 3. Expected Free Energy for each action efe = jnp.zeros(num_actions) @@ -324,6 +329,8 @@ def run_simulation(seed: int = 42): print(f"NumPyro POMDP simulation complete: {{T}} timesteps in {{elapsed:.3f}}s") print(f"Results saved to: {{output_file}}") print(f"Validation: {{validation}}") + if not validation["all_valid"]: + raise RuntimeError("Invalid simulation results; see the persisted validation receipt") return results diff --git a/src/gnn/render/pomdp_processor/_validation.py b/src/gnn/render/pomdp_processor/_validation.py index c4fcf5b4b..a6d16ba75 100644 --- a/src/gnn/render/pomdp_processor/_validation.py +++ b/src/gnn/render/pomdp_processor/_validation.py @@ -38,9 +38,12 @@ def _validate_pomdp_framework_compatibility( unsupported_contract, ) - semantic_spec = {"model_parameters": pomdp_space.model_parameters or {}} - if execution_contract(semantic_spec) is not None: - reason = unsupported_contract(semantic_spec, framework) + semantic_spec = { + "model_parameters": pomdp_space.model_parameters or {}, + "initialparameterization": pomdp_space.initial_parameterization or {}, + } + reason = unsupported_contract(semantic_spec, framework) + if execution_contract(semantic_spec) is not None or reason is not None: return { "compatible": reason is None, "unsupported": reason is not None, diff --git a/src/gnn/render/pytorch/README.md b/src/gnn/render/pytorch/README.md index e7a3d7d77..00d927582 100644 --- a/src/gnn/render/pytorch/README.md +++ b/src/gnn/render/pytorch/README.md @@ -24,3 +24,13 @@ The rendered artifact is a single `.py` file. When executed, it writes `simulati - render-time: `numpy` - generated script run-time: `torch`, `numpy` + +## Discrete filtering contract + +The first observation conditions the authored initial prior. Each later prior +is the previous posterior propagated by the previous selected action's transition +matrix, before conditioning on the new observation. The recorded beliefs are +posteriors. Zero or nonfinite predictive evidence raises an error; invalid result +validation is persisted and then raises an error, so an invalid trace cannot +complete successfully. This is categorical filtering and action scoring, not a +claim of equivalence to other backends' policy objectives. diff --git a/src/gnn/render/pytorch/pytorch_renderer.py b/src/gnn/render/pytorch/pytorch_renderer.py index 1e69074bd..44d24e4d1 100644 --- a/src/gnn/render/pytorch/pytorch_renderer.py +++ b/src/gnn/render/pytorch/pytorch_renderer.py @@ -251,10 +251,15 @@ def run_simulation(): observation = torch.multinomial(obs_probs, 1).item() # 2. Belief update (Bayesian filtering) + # Predict with the action that produced this observation; at t=0 + # the authored initial prior applies without a transition. + prior = beliefs if t == 0 else B[:, :, actions_history[-1]] @ beliefs likelihood = A[observation, :] - posterior = likelihood * beliefs - posterior = posterior / (posterior.sum() + 1e-16) - beliefs = posterior + posterior = likelihood * prior + evidence = posterior.sum() + if not np.isfinite(float(evidence)) or float(evidence) <= 0.0: + raise RuntimeError("Observation has zero or nonfinite predictive probability") + beliefs = posterior / evidence # 3. Compute Expected Free Energy (EFE) for each action efe = torch.zeros(num_actions, dtype=torch.float64) @@ -341,6 +346,8 @@ def run_simulation(): print(f"PyTorch POMDP simulation complete: {{T}} timesteps in {{elapsed:.3f}}s") print(f"Results saved to: {{output_file}}") print(f"Validation: {{validation}}") + if not validation["all_valid"]: + raise RuntimeError("Invalid simulation results; see the persisted validation receipt") return results diff --git a/tests/render/test_cpomdp_contracts.py b/tests/render/test_cpomdp_contracts.py index e060ce2d7..b11267577 100644 --- a/tests/render/test_cpomdp_contracts.py +++ b/tests/render/test_cpomdp_contracts.py @@ -66,7 +66,7 @@ def test_admission_matches_conservative_formula_and_rejects_before_write( assert not artifacts and not destination.exists() with pytest.raises(ValueError, match="max_step_evaluations"): admit_search(2, 1, 2, 5, 1, True, max_step_evaluations=8) - with pytest.raises(ValueError, match="estimated allocation"): + with pytest.raises(ValueError, match="max_total_step_evaluations"): admit_search(2, 1, 2, 1000000, 1, True) with pytest.raises(ValueError, match="ceiling"): admit_search(2, 1, 2, 5, 1, True, max_policies=4097) diff --git a/tests/render/test_cpomdp_finite_controls.py b/tests/render/test_cpomdp_finite_controls.py new file mode 100644 index 000000000..4cb0f54b7 --- /dev/null +++ b/tests/render/test_cpomdp_finite_controls.py @@ -0,0 +1,317 @@ +"""E3-FA1 source control admission and independent released-wheel witnesses.""" + +from __future__ import annotations + +import copy +import itertools +import json +import os +import sys +from pathlib import Path + +import numpy as np +import pytest + +from gnn.execute.subprocess_envelope import run_subprocess_envelope +from gnn.render.cpomdp import render_gnn_to_cpomdp +from gnn.render.cpomdp.admission import admit_search +from gnn.render.processor import render_gnn_spec + + +def _spec(): + return { + "model_kind": "continuous", + "model_name": "finite_controls_witness", + "model_parameters": {"num_timesteps": 4, "random_seed": 19}, + "initialparameterization": { + "F": [[0.8, 0.1, 0.0], [0.0, 0.9, 0.1], [0.1, 0.0, 0.7]], + "H": [[1.0, 0.2, -0.1], [0.0, 0.3, 1.0]], + "Q": [[0.1, 0.01, 0.0], [0.01, 0.2, 0.02], [0.0, 0.02, 0.15]], + "R": [[0.2, 0.03], [0.03, 0.3]], + "prior_mean": [-1.0, 0.2, 0.8], + "prior_cov": [[0.8, 0.1, -0.1], [0.1, 0.6, 0.05], [-0.1, 0.05, 0.9]], + "goal_mean": [1.0, -0.4, 0.2], + "control_gain": 0.1, + "cpomdp_control": { + "contract": "gnn-cpomdp-finite-actions-v1", + "version": "thruster-v1", + "action_ids": ["forward", "reverse", "forward-alias"], + "control_units": ["newton"], + "state_units": ["m/s", "m/s", "m/s"], + "actions": [[0.3], [-0.15], [0.3]], + "control_matrix": [[0.2], [-0.4], [0.7]], + }, + }, + } + + +@pytest.mark.parametrize( + "mutation", + [ + {"contract": "future"}, + {"version": ""}, + {"typo": 1}, + {"action_ids": ["a", "a", "c"]}, + {"action_ids": ["a", "b"]}, + {"control_units": []}, + {"state_units": ["m/s"]}, + {"actions": [[True], [0], [1]]}, + {"actions": [["0.3"], [0], [1]]}, + {"actions": [[float("nan")], [0], [1]]}, + {"actions": [[float("inf")], [0], [1]]}, + {"actions": [[0.0, 1.0], [0.0], [1.0]]}, + {"actions": [[0.0]]}, + {"control_matrix": [[1.0], [True], [0.0]]}, + {"control_matrix": [[1.0], [float("inf")], [0.0]]}, + {"control_matrix": [[1.0, 0.0], [1.0], [0.0]]}, + {"control_matrix": [[1.0], [0.0]]}, + ], +) +def test_bad_source_controls_preserve_existing_artifact(tmp_path, mutation): + spec = _spec() + spec["initialparameterization"]["cpomdp_control"].update(mutation) + dest = tmp_path / "model.py" + dest.write_text("previous artifact") + success, message, artifacts = render_gnn_to_cpomdp(spec, dest) + assert not success and "cpomdp_control" in message and not artifacts + assert dest.read_text() == "previous artifact" + + +@pytest.mark.parametrize("options", [{"control_mode": "parity"}, {"action_scale": 0.5}]) +def test_source_control_semantics_cannot_be_overridden(tmp_path, options): + success, message, _ = render_gnn_to_cpomdp(_spec(), tmp_path / "bad.py", options) + assert not success and "requires efe mode" in message + + +def test_source_controls_cannot_be_silently_made_passive(tmp_path): + spec = _spec() + for key in ("goal_mean", "control_gain"): + del spec["initialparameterization"][key] + success, message, _ = render_gnn_to_cpomdp(spec, tmp_path / "bad.py") + assert not success and "requires goal_mean" in message + + +@pytest.mark.parametrize( + "framework", ["jax", "rxinfer", "numpyro", "pytorch", "ngclearn", "stan"] +) +def test_other_continuous_backends_refuse_source_control_contract(tmp_path, framework): + success, message, artifacts = render_gnn_spec( + _spec(), framework, tmp_path / framework + ) + assert not success and "unsupported-cpomdp-control" in message and not artifacts + + +def test_custom_action_count_and_total_budget_before_allocation(tmp_path): + receipt = admit_search(3, 2, 1, 4, 2, True, action_count=3) + assert receipt["policy_count"] == 9 + assert receipt["step_evaluations_per_cycle"] == 18 + assert receipt["total_step_evaluations"] == 72 + with pytest.raises(ValueError, match="max_policies"): + admit_search(3, 2, 1, 4, 1000000, True, action_count=3) + with pytest.raises(ValueError, match="max_total_step_evaluations"): + admit_search(3, 2, 1, 20000, 2, True, action_count=3) + with pytest.raises(ValueError, match="max_estimated_bytes"): + admit_search(3, 2, 1, 4, 2, True, max_estimated_bytes=1, action_count=3) + spec = _spec() + success, message, artifacts = render_gnn_to_cpomdp( + spec, tmp_path / "huge.py", {"horizon": 1000000} + ) + assert not success and "max_policies" in message and not artifacts + assert not (tmp_path / "huge.py").exists() + + +def _reference(initial, result): + # Source-authored parameters; no generator/extractor or emitted parameter copies. + F, H, Q, R = (np.asarray(initial[k]) for k in ("F", "H", "Q", "R")) + M = np.asarray(initial["cpomdp_control"]["control_matrix"]) + mean, cov = np.array(initial["prior_mean"]), np.array(initial["prior_cov"]) + goal = H @ np.array(initial["goal_mean"]) + policies = list(itertools.product(initial["cpomdp_control"]["actions"], repeat=2)) + means, covs, scores = [], [], [] + for t, obs in enumerate(result["observations_continuous"]): + if t: + mean = F @ mean + M @ np.array(result["control_inputs"][t - 1]) + cov = F @ cov @ F.T + Q + K = np.linalg.solve(H @ cov @ H.T + R, H @ cov).T + mean = mean + K @ (obs - H @ mean) + residual = np.eye(3) - K @ H + cov = residual @ cov @ residual.T + K @ R @ K.T + means.append(mean.copy()) + covs.append(cov.copy()) + row = [] + for policy in policies: + mu, sigma = mean.copy(), cov.copy() + pragmatic = epistemic = 0.0 + for action in policy: + mu = F @ mu + M @ action + sigma = F @ sigma @ F.T + Q + innovation = H @ sigma @ H.T + R + delta = H @ mu - goal + pragmatic += 0.5 * (delta @ delta + np.trace(innovation)) + epistemic += 0.5 * ( + np.linalg.slogdet(innovation)[1] - np.linalg.slogdet(R)[1] + ) + gain = np.linalg.solve(innovation, H @ sigma).T + residual = np.eye(3) - gain @ H + sigma = residual @ sigma @ residual.T + gain @ R @ gain.T + row.append([pragmatic - epistemic, pragmatic, epistemic]) + scores.append(row) + return np.array(means), np.array(covs), np.array(scores) + + +@pytest.mark.needs_cpomdp +def test_native_asymmetric_controls_every_policy_and_authored_tie_order(tmp_path): + spec = _spec() + script = tmp_path / "model.py" + success, message, _ = render_gnn_to_cpomdp(spec, script, {"horizon": 2}) + assert success, message + out = tmp_path / "native" + envelope = run_subprocess_envelope( + [sys.executable, str(script)], + timeout=60, + env=dict(os.environ, CPOMDP_OUTPUT_DIR=str(out)), + ) + assert envelope["success"], envelope + result = json.loads((out / "simulation_results.json").read_text()) + assert result["validation"]["all_valid"] + assert result["cpomdp_version"] == "0.4.4" + declared = spec["initialparameterization"]["cpomdp_control"] + assert result["finite_control_contract"] == declared + assert result["action_set"] == declared["actions"] + assert result["n_policies"] == 9 + assert result["action_set_version"] == "thruster-v1" + means, covs, scores = _reference(spec["initialparameterization"], result) + np.testing.assert_allclose(result["beliefs"], means, rtol=1e-9, atol=1e-10) + np.testing.assert_allclose(result["posterior_cov"], covs, rtol=1e-9, atol=1e-10) + np.testing.assert_allclose( + result["efe_history"], scores[:, :, 0], rtol=1e-9, atol=1e-10 + ) + np.testing.assert_allclose( + result["policy_score_components"]["pragmatic"], + scores[:, :, 1], + rtol=1e-9, + atol=1e-10, + ) + np.testing.assert_allclose( + result["policy_score_components"]["epistemic"], + scores[:, :, 2], + rtol=1e-9, + atol=1e-10, + ) + np.testing.assert_allclose( + result["controls"], + np.array(result["control_inputs"]) @ np.array(declared["control_matrix"]).T, + ) + policies = list(itertools.product(declared["actions"], repeat=2)) + ids = list(itertools.product(declared["action_ids"], repeat=2)) + for t, index in enumerate(result["selected_policy_index"]): + assert index == int(np.argmin(scores[t, :, 0])) + assert "forward-alias" not in result["policy_action_ids"][t] + assert result["policy_action_ids"][t] == list(ids[index]) + assert result["selected_policy"][t] == list(policies[index]) + assert result["control_inputs"][t] == policies[index][0] + # These controls must distinguish the map, prior-time and cross-covariance semantics. + changed = copy.deepcopy(spec["initialparameterization"]) + changed["cpomdp_control"]["control_matrix"] = [[1.0], [0.0], [0.0]] + wrong_means, _, wrong_scores = _reference(changed, result) + assert not np.allclose(wrong_scores, scores) + assert not np.allclose(wrong_means, means) + assert not np.allclose(covs[0], np.diag(np.diag(covs[0]))) + changed["cpomdp_control"] = declared + changed["prior_mean"] = ( + np.array(changed["F"]) @ np.array(changed["prior_mean"]) + ).tolist() + assert not np.allclose(_reference(changed, result)[0], means) + + +def test_source_metadata_identity_and_markdown_pipeline(tmp_path): + from gnn.render import process_render + + spec = _spec() + initial = spec["initialparameterization"] + content = "## ModelName\nfinite_controls_witness\n\n## GNNSection\nContinuous\n\n## InitialParameterization\n" + content += "\n".join(f"{k} = {v!r}" for k, v in initial.items()) + content += "\n\n## ModelParameters\nnum_timesteps = 4\nrandom_seed = 19\n" + source = tmp_path / "input" + source.mkdir() + (source / "controls.md").write_text(content) + out = tmp_path / "rendered" + assert process_render( + source, out, frameworks="cpomdp", backend_options={"cpomdp": {"horizon": 2}} + ) + summary = json.loads((out / "render_processing_summary.json").read_text()) + result = next(iter(summary["file_results"].values()))["framework_results"]["cpomdp"] + assert result["success"], result + program = Path(result["output_files"][0]).read_text() + import ast + + constants = { + node.targets[0].id: ast.literal_eval(node.value) + for node in ast.parse(program).body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id in {"FINITE_CONTROLS", "SOURCE_IDENTITY"} + } + assert constants["FINITE_CONTROLS"] == initial["cpomdp_control"] + direct = tmp_path / "direct.py" + assert render_gnn_to_cpomdp(spec, direct)[0] + old = direct.read_text().split("SOURCE_IDENTITY = ")[1].split("\n")[0] + initial["cpomdp_control"]["action_ids"][0] = "renamed" + assert render_gnn_to_cpomdp(spec, direct)[0] + assert old != direct.read_text().split("SOURCE_IDENTITY = ")[1].split("\n")[0] + + +def test_composed_and_direct_routes_cannot_drop_control_declaration(tmp_path): + from gnn.render.jax import render_gnn_to_jax + + spec = _spec() + spec["model_parameters"]["nr_agents"] = 2 + assert not render_gnn_to_jax(spec, tmp_path / "jax.py")[0] + success, message, _ = render_gnn_spec(spec, "jax", tmp_path / "composed") + assert not success and "unsupported-cpomdp-control" in message + + +def test_finite_control_overflow_is_a_refusal_not_an_exception(tmp_path): + spec = _spec() + spec["initialparameterization"]["cpomdp_control"]["actions"][0][0] = 10**1000 + success, message, artifacts = render_gnn_to_cpomdp(spec, tmp_path / "overflow.py") + assert not success and "finite nonboolean" in message and not artifacts + + +@pytest.mark.parametrize("remove_primary", [False, True]) +def test_parameter_alias_cannot_hide_or_change_source_controls( + tmp_path, remove_primary +): + spec = _spec() + spec["initial_parameterization"] = copy.deepcopy(spec["initialparameterization"]) + if remove_primary: + del spec["initialparameterization"]["cpomdp_control"] + else: + spec["initial_parameterization"]["cpomdp_control"]["actions"][0][0] = 0.7 + success, message, artifacts = render_gnn_to_cpomdp(spec, tmp_path / "aliases.py") + assert not success and "declarations differ" in message and not artifacts + success, message, _ = render_gnn_spec(spec, "jax", tmp_path / "jax") + assert not success and "unsupported-cpomdp-control" in message + + +@pytest.mark.parametrize("declaration", [True, False]) +@pytest.mark.parametrize("composition", ["multi_agent", "hybrid", "factored"]) +def test_direct_cpomdp_refuses_composed_models_before_replacing_artifact( + tmp_path, declaration, composition +): + spec = _spec() + if not declaration: + del spec["initialparameterization"]["cpomdp_control"] + if composition == "multi_agent": + spec["model_parameters"]["nr_agents"] = 2 + elif composition == "hybrid": + spec["initialparameterization"]["A"] = [[1.0]] + else: + spec["model_parameters"]["num_factors"] = 2 + spec["initialparameterization"]["F_f0"] = spec["initialparameterization"]["F"] + path = tmp_path / "composed.py" + path.write_text("previous artifact") + success, message, artifacts = render_gnn_to_cpomdp(spec, path) + assert not success and "unsupported-composition:" in message and not artifacts + assert path.read_text() == "previous artifact" diff --git a/tests/render/test_discrete_filter_prediction.py b/tests/render/test_discrete_filter_prediction.py new file mode 100644 index 000000000..f4e99cd85 --- /dev/null +++ b/tests/render/test_discrete_filter_prediction.py @@ -0,0 +1,176 @@ +"""Independent native filtering/EFE witnesses and invalid-result controls.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from importlib import import_module +from pathlib import Path + +import numpy as np +import pytest + + +def _spec(asymmetric: bool) -> dict: + if asymmetric: + a = [[0.95, 0.25, 0.5], [0.05, 0.75, 0.5]] + b = np.stack( + [ + [[0.7, 0.1, 0.2], [0.2, 0.6, 0.3], [0.1, 0.3, 0.5]], + [[0.1, 0.3, 0.5], [0.4, 0.6, 0.2], [0.5, 0.1, 0.3]], + ], + axis=2, + ).tolist() + d = [0.6, 0.3, 0.1] + c = [0.0, 1.0] + else: + a = np.eye(2).tolist() + b = [[[0.0], [1.0]], [[1.0], [0.0]]] + d = [1.0, 0.0] + c = [0.0, 0.0] + return { + "modelName": "filter_prediction_witness", + "model_parameters": { + "num_hidden_states": len(d), + "num_obs": len(a), + "num_actions": len(b[0][0]), + "num_timesteps": 12, + "b_tensor_order": "next_state_previous_state_action", + }, + "initialparameterization": {"A": a, "B": b, "C": c, "D": d}, + } + + +def _render(backend: str, spec: dict, path: Path) -> None: + if backend == "pytorch": + import_module("torch") + from gnn.render.pytorch import render_gnn_to_pytorch as render + else: + import_module("numpyro") + from gnn.render.numpyro import render_gnn_to_numpyro as render + okay, message, _ = render(spec, path) + assert okay, message + + +def _run(backend: str, script: Path, output: Path, *, x64: bool, probe: str = ""): + env = dict( + os.environ, + **{backend.upper() + "_OUTPUT_DIR": str(output)}, + JAX_ENABLE_X64=str(x64).lower(), + ) + code = ( + "import runpy, sys\n" + + ("import torch; torch.manual_seed(821)\n" if backend == "pytorch" else "") + + (probe or "runpy.run_path(sys.argv[1], run_name='__main__')\n") + ) + return subprocess.run( + [sys.executable, "-c", code, str(script)], + cwd=script.parent, + env=env, + capture_output=True, + text=True, + timeout=90, + ) + + +@pytest.mark.env_heavy +@pytest.mark.parametrize( + "backend,x64", [("pytorch", False), ("numpyro", False), ("numpyro", True)] +) +@pytest.mark.parametrize( + "asymmetric", [False, True], ids=["deterministic-flip", "asymmetric"] +) +def test_native_filter_and_every_action_efe_match_numpy( + tmp_path: Path, backend: str, x64: bool, asymmetric: bool +) -> None: + spec = _spec(asymmetric) + script = tmp_path / "model.py" + _render(backend, spec, script) + output = tmp_path / "native" + run = _run(backend, script, output, x64=x64) + assert run.returncode == 0, run.stderr + result = json.loads((output / "simulation_results.json").read_text()) + assert result["validation"]["all_valid"] + params = spec["initialparameterization"] + a, b, c, posterior = [ + np.asarray(params[k], dtype=float) for k in ["A", "B", "C", "D"] + ] + pref = np.exp(c - c.max()) + pref /= pref.sum() + expected_beliefs, expected_efe = [], [] + for t, obs in enumerate(result["observations"]): + prior = posterior if t == 0 else b[:, :, result["actions"][t - 1]] @ posterior + joint = a[obs] * prior + assert joint.sum() > 0 + posterior = joint / joint.sum() + expected_beliefs.append(posterior.copy()) + scores = [] + for action in range(b.shape[2]): + predicted_state = b[:, :, action] @ posterior + predicted_obs = a @ predicted_state + ambiguity = -np.dot(predicted_state, np.sum(a * np.log(a + 1e-16), axis=0)) + risk = np.sum( + predicted_obs * (np.log(predicted_obs + 1e-16) - np.log(pref + 1e-16)) + ) + scores.append(ambiguity + risk) + expected_efe.append(scores) + tolerance = 2e-6 if backend == "numpyro" and not x64 else 1e-10 + np.testing.assert_allclose( + result["beliefs"], expected_beliefs, rtol=tolerance, atol=tolerance + ) + np.testing.assert_allclose( + result["efe_history"], expected_efe, rtol=tolerance, atol=tolerance + ) + assert len(result["beliefs"]) == 12 + if not asymmetric: + np.testing.assert_allclose( + result["beliefs"], np.tile(np.eye(2), (6, 1)), atol=tolerance + ) + + +@pytest.mark.env_heavy +@pytest.mark.parametrize("backend", ["pytorch", "numpyro"]) +def test_zero_predictive_evidence_fails_before_fabricating_beliefs( + tmp_path: Path, backend: str +) -> None: + script = tmp_path / "model.py" + _render(backend, _spec(False), script) + probe = "module = runpy.run_path(sys.argv[1], run_name='probe')\nchoices = iter([0, 1])\n" + if backend == "pytorch": + probe += "module['torch'].multinomial = lambda *a, **k: module['torch'].tensor([next(choices)])\n" + else: + probe += "sampler = type(module['dist'].Categorical(probs=module['jnp'].asarray([1., 0.])))\n" + probe += "sampler.sample = lambda self, *a, **k: module['jnp'].asarray(next(choices))\n" + probe += "module['run_simulation']()\n" + output = tmp_path / "native" + run = _run(backend, script, output, x64=False, probe=probe) + assert run.returncode != 0 + assert "zero or nonfinite predictive probability" in run.stderr + assert not (output / "simulation_results.json").exists() + + +@pytest.mark.env_heavy +@pytest.mark.parametrize("backend", ["pytorch", "numpyro"]) +def test_invalid_persisted_validation_cannot_exit_successfully( + tmp_path: Path, backend: str +) -> None: + script = tmp_path / "model.py" + _render(backend, _spec(False), script) + code = script.read_text() + original = 'validation["all_valid"] = all(validation.values())' + assert code.count(original) == 1 + # A false-result control exercises the public process boundary after saving + # the artifact, independent of which numerical invariant rejected the run. + script.write_text(code.replace(original, 'validation["all_valid"] = False')) + output = tmp_path / "native" + run = _run(backend, script, output, x64=False) + assert run.returncode != 0 + assert "Invalid simulation results" in run.stderr + assert ( + json.loads((output / "simulation_results.json").read_text())["validation"][ + "all_valid" + ] + is False + )