From dd71e0e713c66dbe4c3a29ef48dfd7ca8a92c0a7 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:27:34 +0400 Subject: [PATCH 1/7] Import the Orrery projection without numpy, pandas or the content store A collector worker is to project Orrery artifacts from a saved compiler schema and execution overlay, without the build machine's content store. Importing microcosm.graph.orrery pulled in the whole build runtime for two reasons: orrery_evidence took one protocol string and a hash helper from evidence, which imports the store, kernels and manifests; and the package init eagerly imported the executor, store, kernels, codecs and evidence, so any microcosm.graph module brought numpy, pandas and microcosm-frame with it. The package init now keeps declarations, the schema, serialization and weight-update receipts eager and loads the runtime exports on first access (PEP 562); __all__ and every public name are unchanged. The overlay protocol moves to a new standard-library-only execution_overlay module and sha256 to canonical; evidence imports both, so its names stay importable. The tests run the projection and the command in an interpreter that refuses those imports and require the in-process bytes. Declared dependencies are unchanged. Refs #1294 Co-Authored-By: Claude Opus 5.5 --- ...orrery-projection-without-store.changed.md | 1 + docs/orrery-adapter.md | 8 + .../src/microcosm/graph/__init__.py | 157 ++++++++------ .../src/microcosm/graph/canonical.py | 8 +- .../src/microcosm/graph/evidence.py | 9 +- .../src/microcosm/graph/execution_overlay.py | 14 ++ .../src/microcosm/graph/orrery_evidence.py | 4 +- ...t_graph_orrery_projection_without_store.py | 196 ++++++++++++++++++ 8 files changed, 322 insertions(+), 75 deletions(-) create mode 100644 changelog.d/1294-orrery-projection-without-store.changed.md create mode 100644 packages/microcosm-graph/src/microcosm/graph/execution_overlay.py create mode 100644 packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery_projection_without_store.py diff --git a/changelog.d/1294-orrery-projection-without-store.changed.md b/changelog.d/1294-orrery-projection-without-store.changed.md new file mode 100644 index 0000000000..4d206b8f19 --- /dev/null +++ b/changelog.d/1294-orrery-projection-without-store.changed.md @@ -0,0 +1 @@ +Load `microcosm.graph`'s runtime exports on first access, and take the execution overlay's protocol name and the SHA-256 helper from modules that need only the standard library. `microcosm.graph.orrery` now imports and projects a saved schema and execution overlay in a process without numpy, pandas, microcosm-frame or the content store; the package's declared dependencies are unchanged. diff --git a/docs/orrery-adapter.md b/docs/orrery-adapter.md index b095093260..6d53c63692 100644 --- a/docs/orrery-adapter.md +++ b/docs/orrery-adapter.md @@ -24,6 +24,14 @@ Microcosm executes. The direct API performs steps 2 and 3 in memory. A saved schema lets another process repeat step 3 without importing the original graph construction code. +That process needs none of the build runtime either. `microcosm.graph.orrery` +and the modules it imports use only the standard library, and `microcosm.graph` +loads its runtime exports (the executor, content store, kernels, codecs and +evidence capture, which need numpy, pandas and microcosm-frame) on first +access. A test runs the projection, with an execution overlay, and the command +in an interpreter that refuses to import numpy, pandas, microcosm-frame or the +store. The package's declared dependencies are unchanged. + A kernel is the implementation of one graph operation, with a declared input, output, numerical and execution contract. diff --git a/packages/microcosm-graph/src/microcosm/graph/__init__.py b/packages/microcosm-graph/src/microcosm/graph/__init__.py index 28dde48d34..a3caf57a6b 100644 --- a/packages/microcosm-graph/src/microcosm/graph/__init__.py +++ b/packages/microcosm-graph/src/microcosm/graph/__init__.py @@ -2,11 +2,18 @@ Public API. ``decl`` and ``kernel`` are the frozen interfaces; the runtime modules implement execution, storage, provenance, and inspection against them. + +Declarations, the compiler schema and the Orrery projection need only the +standard library, so they import eagerly. Exports from the runtime modules, +which need numpy, pandas and microcosm-frame, load on first access: a process +that only projects saved schemas and execution overlays imports +``microcosm.graph.orrery`` without those dependencies. """ from __future__ import annotations from collections.abc import Mapping +from importlib import import_module from importlib import metadata as _metadata from .decl import ( @@ -42,25 +49,8 @@ StoreMissError, StoreUnavailableError, ) -from .kernel import ( - ArtifactValue, - Capabilities, - Determinism, - Kernel, - KernelBase, - KernelContext, - KernelRegistry, - KernelResult, - KernelRole, - Numeric, - NumericScope, - SeedSource, - Tolerance, - source_hash, -) -from .keys import platform_fingerprint -from .randomness import keyed_uniform from .schema import graph_schema +from .serialize import graph_from_json, graph_to_json from .weight_update import WEIGHT_UPDATE_AXIS_SCHEMA, weight_update_receipt __all__ = [ @@ -139,8 +129,76 @@ "weight_update_receipt", "orrery_document", "orrery_json", + "ArtifactSummaryContext", + "RecordedRun", + "checkpoint_references", + "collect_execution_evidence", + "load_run_evidence", + "publish_run_evidence", + "record_run_binding", + "save_graph_schema", + "save_run_evidence", ] +#: The runtime modules and the exports each one supplies on first access. +_RUNTIME_MODULE_EXPORTS: dict[str, tuple[str, ...]] = { + "codecs": ( + "SOURCE_CODECS", + "SourceBytesCodec", + "SourceCodec", + "SourceCodecRegistry", + "load_source", + "load_source_bytes", + ), + "evidence": ( + "ArtifactSummaryContext", + "RecordedRun", + "checkpoint_references", + "collect_execution_evidence", + "load_run_evidence", + "publish_run_evidence", + "record_run_binding", + "save_graph_schema", + "save_run_evidence", + ), + "executor": ("NodeRejected", "run_graph"), + "explain": ("explain_html",), + "kernel": ( + "ArtifactValue", + "Capabilities", + "Determinism", + "Kernel", + "KernelBase", + "KernelContext", + "KernelRegistry", + "KernelResult", + "KernelRole", + "Numeric", + "NumericScope", + "SeedSource", + "Tolerance", + "source_hash", + ), + "keys": ("platform_fingerprint",), + "manifest": ("Decision", "NodeReceipt", "PopulationView", "RunManifest"), + "population": ("MassRecord", "Population", "PopulationError"), + "randomness": ("keyed_uniform",), + "store": ( + "ContentStore", + "ResumePolicy", + "StoreCorrupt", + "StoreError", + "StoreMiss", + "StoreUnavailable", + ), + "view": ("describe",), +} +_RUNTIME_EXPORTS = { + export: module_name + for module_name, exports in _RUNTIME_MODULE_EXPORTS.items() + for export in exports +} + _FRAME_SERIES = "0.1" @@ -156,9 +214,25 @@ def _check_frame_version() -> None: ) +# The installed series is checked before any runtime module can be imported. _check_frame_version() +def __getattr__(name: str) -> object: + """Import a runtime export's defining module the first time it is used.""" + + module_name = _RUNTIME_EXPORTS.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(import_module(f"{__name__}.{module_name}"), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted({*globals(), *_RUNTIME_EXPORTS}) + + def orrery_document( graph: Graph | CompiledGraph, *, @@ -185,50 +259,3 @@ def orrery_json( from .orrery import orrery_json as export return export(graph, title=title, extensions=extensions, execution=execution) - - -from .codecs import ( # noqa: E402 - check dependency series before runtime import - SOURCE_CODECS, - SourceBytesCodec, - SourceCodec, - SourceCodecRegistry, - load_source, - load_source_bytes, -) -from .evidence import ( # noqa: E402 - ArtifactSummaryContext, - RecordedRun, - checkpoint_references, - collect_execution_evidence, - load_run_evidence, - publish_run_evidence, - record_run_binding, - save_graph_schema, - save_run_evidence, -) -from .executor import NodeRejected, run_graph # noqa: E402 -from .explain import explain_html # noqa: E402 -from .manifest import Decision, NodeReceipt, PopulationView, RunManifest # noqa: E402 -from .population import MassRecord, Population, PopulationError # noqa: E402 -from .serialize import graph_from_json, graph_to_json # noqa: E402 -from .store import ( # noqa: E402 - ContentStore, - ResumePolicy, - StoreCorrupt, - StoreError, - StoreMiss, - StoreUnavailable, -) -from .view import describe # noqa: E402 - -__all__ += [ - "ArtifactSummaryContext", - "RecordedRun", - "checkpoint_references", - "collect_execution_evidence", - "load_run_evidence", - "publish_run_evidence", - "record_run_binding", - "save_graph_schema", - "save_run_evidence", -] diff --git a/packages/microcosm-graph/src/microcosm/graph/canonical.py b/packages/microcosm-graph/src/microcosm/graph/canonical.py index 50e6dd65c8..a9e80411af 100644 --- a/packages/microcosm-graph/src/microcosm/graph/canonical.py +++ b/packages/microcosm-graph/src/microcosm/graph/canonical.py @@ -12,7 +12,7 @@ from .decl import DESCRIPTIVE_FIELDS -__all__ = ["canonical_json", "normative", "sha256_domain"] +__all__ = ["canonical_json", "normative", "sha256", "sha256_domain"] def _json_value(value: object) -> object: @@ -58,6 +58,12 @@ def canonical_json(obj: object) -> bytes: ).encode("utf-8") +def sha256(payload: bytes) -> str: + """Return the lowercase hexadecimal SHA-256 digest of ``payload``.""" + + return hashlib.sha256(payload).hexdigest() + + def sha256_domain(domain: str, payload: bytes) -> str: """Hash ``payload`` after a UTF-8 domain and a NUL separator.""" diff --git a/packages/microcosm-graph/src/microcosm/graph/evidence.py b/packages/microcosm-graph/src/microcosm/graph/evidence.py index 8b887a05b7..690cef4730 100644 --- a/packages/microcosm-graph/src/microcosm/graph/evidence.py +++ b/packages/microcosm-graph/src/microcosm/graph/evidence.py @@ -7,7 +7,6 @@ from __future__ import annotations -import hashlib import json import logging import math @@ -20,7 +19,7 @@ from urllib.parse import urlsplit from .availability import execution_state, unavailable_artifacts -from .canonical import canonical_json, normative +from .canonical import canonical_json, normative, sha256 from .decl import ( GATE_OUTCOMES, CompiledGraph, @@ -29,6 +28,7 @@ StructuralDelta, compile_graph, ) +from .execution_overlay import EXECUTION_PROTOCOL from .kernel import Capabilities, KernelRole from .keys import ( artifact_key, @@ -52,7 +52,6 @@ BINDING_PROTOCOL = "microcosm.graph.run-binding.v1" EVIDENCE_INPUT_PROTOCOL = "microcosm.graph.execution-input.v1" -EXECUTION_PROTOCOL = "microcosm.graph.execution-evidence.v1" # Native evidence files are exact copies of run manifests and graphs, which a # real build already holds in memory; they are bounded by bytes and nesting # depth only. The per-item complexity charge of the compiler-schema reader is @@ -69,10 +68,6 @@ _LOG = logging.getLogger(__name__) -def sha256(payload: bytes) -> str: - return hashlib.sha256(payload).hexdigest() - - def _plain(value: object) -> object: return _plain_json(json.loads(canonical_json(value))) diff --git a/packages/microcosm-graph/src/microcosm/graph/execution_overlay.py b/packages/microcosm-graph/src/microcosm/graph/execution_overlay.py new file mode 100644 index 0000000000..dff71d10e5 --- /dev/null +++ b/packages/microcosm-graph/src/microcosm/graph/execution_overlay.py @@ -0,0 +1,14 @@ +"""The execution overlay's protocol, shared by evidence capture and projection. + +:func:`microcosm.graph.evidence.collect_execution_evidence` verifies recorded +runs against the content store and returns the overlay; +:mod:`microcosm.graph.orrery` projects it. This module needs only the standard +library, so a process that only projects needs neither the store nor numpy, +pandas or microcosm-frame. +""" + +from __future__ import annotations + +__all__ = ["EXECUTION_PROTOCOL"] + +EXECUTION_PROTOCOL = "microcosm.graph.execution-evidence.v1" diff --git a/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py b/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py index 79670dbafa..dee1b6597e 100644 --- a/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py +++ b/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py @@ -2,8 +2,8 @@ from __future__ import annotations -from .canonical import canonical_json -from .evidence import EXECUTION_PROTOCOL, sha256 +from .canonical import canonical_json, sha256 +from .execution_overlay import EXECUTION_PROTOCOL from .presentation import ( digest, presentation_id, diff --git a/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery_projection_without_store.py b/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery_projection_without_store.py new file mode 100644 index 0000000000..103d72024e --- /dev/null +++ b/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery_projection_without_store.py @@ -0,0 +1,196 @@ +"""The Orrery projection runs without the build runtime. + +A collector worker will project a saved compiler schema and execution overlay +without the content store or the population libraries. These tests run that +projection in a fresh interpreter that refuses to import numpy, pandas, +microcosm-frame or the store, and compare its bytes with the projection a +build makes in-process. +""" + +import importlib.util +import json +import subprocess +import sys + +import pytest + +import microcosm.graph as graph_api +from microcosm.graph import ( + collect_execution_evidence, + graph_schema, + record_run_binding, +) +from microcosm.graph.canonical import canonical_json +from microcosm.graph.orrery import main, orrery_json_from_schema +from microcosm.graph.presentation import PRESENTATION_EXTENSION, PRESENTATION_PROTOCOL +from test_support.paths import paths_for + +_PATHS = paths_for("microcosm-graph") +if "_toy" not in sys.modules: + _SPEC = importlib.util.spec_from_file_location("_toy", _PATHS.tests / "_toy.py") + _TOY = importlib.util.module_from_spec(_SPEC) + sys.modules["_toy"] = _TOY + _SPEC.loader.exec_module(_TOY) +toy = sys.modules["_toy"] + +#: Modules a projection process must be able to do without. +RUNTIME_MODULES = ("numpy", "pandas", "microcosm.frame", "microcosm.graph.store") + +#: Installs the refusal before anything else imports, then runs the body. +_REFUSE_RUNTIME_IMPORTS = r""" +import importlib.abc, json, sys + +refused = tuple(sys.argv[1].split(",")) + + +def is_refused(name): + return any(name == prefix or name.startswith(prefix + ".") for prefix in refused) + + +class RefuseRuntimeImports(importlib.abc.MetaPathFinder): + def find_spec(self, name, path=None, target=None): + if is_refused(name): + raise ImportError(f"the Orrery projection imported {name}") + return None + + +sys.meta_path.insert(0, RefuseRuntimeImports()) +""" + +_RESTRICTED_PROJECTION = ( + _REFUSE_RUNTIME_IMPORTS + + r""" +from pathlib import Path + +from microcosm.graph.orrery import orrery_json_from_schema + +schema_path, overlay_path, output_path = (Path(argument) for argument in sys.argv[2:5]) +schema = json.loads(schema_path.read_bytes()) +overlay = json.loads(overlay_path.read_bytes()) +output_path.write_text( + orrery_json_from_schema(schema, execution=overlay), encoding="utf-8" +) +print(json.dumps(sorted(name for name in sys.modules if is_refused(name)))) +""" +) + +_RESTRICTED_COMMAND = ( + _REFUSE_RUNTIME_IMPORTS + + r""" +import runpy + +sys.argv = ["microcosm.graph.orrery", *sys.argv[2:]] +runpy.run_module("microcosm.graph.orrery", run_name="__main__") +""" +) + + +def _run_restricted(script: str, *arguments: str, cwd) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", script, ",".join(RUNTIME_MODULES), *arguments], + capture_output=True, + text=True, + timeout=300, + check=False, + cwd=cwd, + ) + + +@pytest.fixture +def recorded(tmp_path): + """A computing phase and a replay of the toy graph, with their schema.""" + computed = toy.run_toy(toy.full_graph(), tmp_path) + replayed = toy.run_toy( + computed.compiled.graph, + tmp_path, + sources=computed.sources, + registry=computed.registry, + store=computed.store, + ) + schema = graph_schema( + computed.compiled, + extensions={ + PRESENTATION_EXTENSION: { + "protocol": PRESENTATION_PROTOCOL, + "scope": {"id": "fixture", "label": "Synthetic country"}, + "groups": [ + {"id": "sources", "label": "Sources", "sources": ["survey"]}, + { + "id": "work", + "label": "Processing", + "operations": list(computed.compiled.order), + }, + ], + } + }, + ) + runs = [ + record_run_binding( + computed.compiled, computed.manifest, attempt_id="attempt", phase="first" + ), + record_run_binding( + replayed.compiled, replayed.manifest, attempt_id="attempt", phase="replay" + ), + ] + overlay = collect_execution_evidence(schema, runs=runs, store=computed.store) + return schema, overlay + + +def test_orrery_projection_runs_where_numpy_pandas_frame_and_store_cannot_import( + recorded, tmp_path +): + schema, overlay = recorded + schema_path = tmp_path / "graph.schema.json" + overlay_path = tmp_path / "graph.execution.json" + output_path = tmp_path / "graph.orrery.json" + schema_path.write_bytes(canonical_json(schema)) + overlay_path.write_bytes(canonical_json(overlay)) + + completed = _run_restricted( + _RESTRICTED_PROJECTION, + str(schema_path), + str(overlay_path), + str(output_path), + cwd=tmp_path, + ) + + assert completed.returncode == 0, completed.stderr[-4000:] + assert json.loads(completed.stdout) == [] + assert output_path.read_bytes() == ( + orrery_json_from_schema(schema, execution=overlay).encode("utf-8") + ) + + +def test_orrery_command_runs_where_numpy_pandas_frame_and_store_cannot_import( + recorded, tmp_path +): + schema, _overlay = recorded + schema_path = tmp_path / "graph.schema.json" + schema_path.write_bytes(canonical_json(schema)) + restricted_output = tmp_path / "restricted" / "graph.orrery.json" + in_process_output = tmp_path / "in-process" / "graph.orrery.json" + + completed = _run_restricted( + _RESTRICTED_COMMAND, + "--schema", + str(schema_path), + "--output", + str(restricted_output), + cwd=tmp_path, + ) + + assert completed.returncode == 0, completed.stderr[-4000:] + assert main(["--schema", str(schema_path), "--output", str(in_process_output)]) == 0 + assert restricted_output.read_bytes() == in_process_output.read_bytes() + + +def test_package_runtime_exports_still_resolve_from_the_package(): + from microcosm.graph import executor, store + + for name in graph_api.__all__: + assert getattr(graph_api, name) is not None, name + assert set(graph_api.__all__) <= set(dir(graph_api)) + assert graph_api.ContentStore is store.ContentStore + assert graph_api.run_graph is executor.run_graph + with pytest.raises(AttributeError, match="not_an_export"): + graph_api.not_an_export # noqa: B018 From 632c266cb8928d71ad10b8c534f8fbf417d9cb04 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:46:32 +0400 Subject: [PATCH 2/7] Read and write the execution overlay as its own file The execution overlay is the projection's only input besides the schema, but it existed only in memory and, once projected, embedded at metadata.execution of the Orrery artifact. A collector worker that projects later needs it as a file it can read without the store. execution_overlay_bytes serializes an overlay as canonical JSON, and load_execution_overlay reads it back as an equal object, so projecting the file gives byte-identical Orrery JSON. The file keeps the overlay's existing protocol, microcosm.graph.execution-evidence.v1, rather than adding a wrapper protocol. The reader is standard-library only. It refuses non-regular files and files over the schema's 32 MiB serialized limit (which collection and projection already apply to the overlay) before parsing. It also refuses duplicate keys, non-finite numbers, other protocols and values outside the schema's JSON bounds. Graph binding and record checks stay in the projection. The restricted-import test now reads the overlay through this reader. Refs #1294 Co-Authored-By: Claude Opus 5.5 --- .../1294-execution-overlay-file.added.md | 1 + docs/orrery-adapter.md | 33 +++++++ .../src/microcosm/graph/execution_overlay.py | 76 ++++++++++++++- ...t_graph_orrery_projection_without_store.py | 92 ++++++++++++++++++- 4 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 changelog.d/1294-execution-overlay-file.added.md diff --git a/changelog.d/1294-execution-overlay-file.added.md b/changelog.d/1294-execution-overlay-file.added.md new file mode 100644 index 0000000000..3837e6dc96 --- /dev/null +++ b/changelog.d/1294-execution-overlay-file.added.md @@ -0,0 +1 @@ +Add `microcosm.graph.execution_overlay.execution_overlay_bytes` and `load_execution_overlay`, which write and read the execution overlay (`microcosm.graph.execution-evidence.v1`) as its own canonical JSON file. Projecting a schema with the overlay read back from that file gives byte-identical Orrery JSON, and the reader needs only the standard library. It refuses non-regular files, files over the schema's 32 MiB serialized limit, duplicate keys, non-finite numbers and other protocols. diff --git a/docs/orrery-adapter.md b/docs/orrery-adapter.md index 6d53c63692..b1bee81c8c 100644 --- a/docs/orrery-adapter.md +++ b/docs/orrery-adapter.md @@ -251,6 +251,39 @@ npx orrery --input build/graph.orrery.json --output build/graph.orrery.html Native evidence links and saved-schema relative references are rebased to the command's output directory; keep the referenced files available with the HTML. +`collect_execution_evidence` returns the execution overlay: the +`microcosm.graph.execution-evidence.v1` object that the projection embeds at +`metadata.execution`. `microcosm.graph.execution_overlay` saves it as its own +file, so a later projection needs only the schema and that file, not the store: + +```python +from microcosm.graph.execution_overlay import ( + execution_overlay_bytes, + load_execution_overlay, +) + +Path("build/graph.execution.json").write_bytes(execution_overlay_bytes(execution)) + +# Later, in a process without the store, numpy, pandas or microcosm-frame: +schema = json.loads(Path("build/graph.schema.json").read_bytes()) +overlay = load_execution_overlay(Path("build/graph.execution.json")) +Path("build/graph.orrery.json").write_text( + orrery_json_from_schema(schema, execution=overlay), encoding="utf-8" +) +``` + +The file is the overlay's canonical JSON: UTF-8, sorted keys and no +whitespace. It has none of the transport tags for numbers JavaScript cannot +represent; the projection adds those. Reading it back gives an equal object, +so the projection is byte-identical to projecting the in-memory overlay. The +reader takes only a regular file within the schema's 32 MiB serialized limit, +which evidence collection and projection already apply to the overlay. It +refuses duplicate keys, non-finite numbers, values outside the schema's JSON +bounds and any other protocol. The overlay's graph binding and records are +checked when it is projected. Its native-evidence links are relative to the +directory it was collected for (`reference_base`), so project into that +directory, beside the linked files. + Other countries supply the same three inputs: a presentation dictionary (groups, scope, optional labels and references), an artifact-summary registry keyed by artifact type, and driver wiring that calls `save_graph_schema`, diff --git a/packages/microcosm-graph/src/microcosm/graph/execution_overlay.py b/packages/microcosm-graph/src/microcosm/graph/execution_overlay.py index dff71d10e5..124cebf91e 100644 --- a/packages/microcosm-graph/src/microcosm/graph/execution_overlay.py +++ b/packages/microcosm-graph/src/microcosm/graph/execution_overlay.py @@ -1,14 +1,80 @@ -"""The execution overlay's protocol, shared by evidence capture and projection. +"""The execution overlay as its own file, shared by evidence capture and projection. :func:`microcosm.graph.evidence.collect_execution_evidence` verifies recorded runs against the content store and returns the overlay; -:mod:`microcosm.graph.orrery` projects it. This module needs only the standard -library, so a process that only projects needs neither the store nor numpy, -pandas or microcosm-frame. +:mod:`microcosm.graph.orrery` projects it. The file holds that same object as +canonical JSON, so a process that only projects can read it back. This module +needs only the standard library, so that process needs neither the store nor +numpy, pandas or microcosm-frame. """ from __future__ import annotations -__all__ = ["EXECUTION_PROTOCOL"] +import json +import os +import stat +from pathlib import Path + +from .canonical import canonical_json +from .presentation import require +from .schema import _MAX_BYTES as _MAX_OVERLAY_BYTES +from .schema import _plain_json + +__all__ = ["EXECUTION_PROTOCOL", "execution_overlay_bytes", "load_execution_overlay"] EXECUTION_PROTOCOL = "microcosm.graph.execution-evidence.v1" + + +def _overlay_object(value: object) -> dict: + require( + type(value) is dict and value.get("protocol") == EXECUTION_PROTOCOL, + f"execution overlay must be a {EXECUTION_PROTOCOL} object", + ) + return value # type: ignore[return-value] + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + require(key not in result, "duplicate JSON key in execution overlay") + result[key] = value + return result + + +def _reject_constant(_value: str) -> None: + raise ValueError("Graph evidence: execution overlay requires finite JSON numbers.") + + +def execution_overlay_bytes(overlay: object) -> bytes: + """Serialize an overlay from ``collect_execution_evidence`` as canonical JSON. + + :func:`load_execution_overlay` reads the bytes back as an equal object, so + projecting it gives the same Orrery JSON as projecting ``overlay`` itself. + """ + + return canonical_json(_overlay_object(_plain_json(overlay))) + + +def load_execution_overlay(path: Path) -> dict: + """Read an overlay file written from :func:`execution_overlay_bytes`. + + Only a UTF-8 regular file within the schema's serialized size limit, which + evidence collection and projection both apply to the overlay, is read. + Duplicate keys, non-finite numbers, values outside the schema's JSON bounds + and any other protocol are refused. The overlay's graph binding and its + records are checked when it is projected. + """ + + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(stream.fileno()) + require(stat.S_ISREG(info.st_mode), "execution overlay must be a regular file") + require(info.st_size <= _MAX_OVERLAY_BYTES, "execution overlay byte limit") + payload = stream.read(_MAX_OVERLAY_BYTES + 1) + require(len(payload) <= _MAX_OVERLAY_BYTES, "execution overlay byte limit") + value = json.loads( + payload.decode("utf-8"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + return _overlay_object(_plain_json(value)) diff --git a/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery_projection_without_store.py b/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery_projection_without_store.py index 103d72024e..29ae544dae 100644 --- a/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery_projection_without_store.py +++ b/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery_projection_without_store.py @@ -1,14 +1,15 @@ """The Orrery projection runs without the build runtime. A collector worker will project a saved compiler schema and execution overlay -without the content store or the population libraries. These tests run that -projection in a fresh interpreter that refuses to import numpy, pandas, +file without the content store or the population libraries. These tests run +that projection in a fresh interpreter that refuses to import numpy, pandas, microcosm-frame or the store, and compare its bytes with the projection a build makes in-process. """ import importlib.util import json +import os import subprocess import sys @@ -17,6 +18,7 @@ import microcosm.graph as graph_api from microcosm.graph import ( collect_execution_evidence, + execution_overlay, graph_schema, record_run_binding, ) @@ -62,11 +64,12 @@ def find_spec(self, name, path=None, target=None): + r""" from pathlib import Path +from microcosm.graph.execution_overlay import load_execution_overlay from microcosm.graph.orrery import orrery_json_from_schema schema_path, overlay_path, output_path = (Path(argument) for argument in sys.argv[2:5]) schema = json.loads(schema_path.read_bytes()) -overlay = json.loads(overlay_path.read_bytes()) +overlay = load_execution_overlay(overlay_path) output_path.write_text( orrery_json_from_schema(schema, execution=overlay), encoding="utf-8" ) @@ -144,7 +147,7 @@ def test_orrery_projection_runs_where_numpy_pandas_frame_and_store_cannot_import overlay_path = tmp_path / "graph.execution.json" output_path = tmp_path / "graph.orrery.json" schema_path.write_bytes(canonical_json(schema)) - overlay_path.write_bytes(canonical_json(overlay)) + overlay_path.write_bytes(execution_overlay.execution_overlay_bytes(overlay)) completed = _run_restricted( _RESTRICTED_PROJECTION, @@ -194,3 +197,84 @@ def test_package_runtime_exports_still_resolve_from_the_package(): assert graph_api.run_graph is executor.run_graph with pytest.raises(AttributeError, match="not_an_export"): graph_api.not_an_export # noqa: B018 + + +def test_execution_overlay_file_round_trips_to_byte_identical_orrery_json( + recorded, tmp_path +): + schema, overlay = recorded + overlay_path = tmp_path / "graph.execution.json" + overlay_path.write_bytes(execution_overlay.execution_overlay_bytes(overlay)) + + loaded = execution_overlay.load_execution_overlay(overlay_path) + + assert overlay_path.read_bytes() == canonical_json(overlay) + assert loaded["protocol"] == "microcosm.graph.execution-evidence.v1" + assert loaded == overlay + assert orrery_json_from_schema(schema, execution=loaded) == ( + orrery_json_from_schema(schema, execution=overlay) + ) + + +_OVERLAY_PROTOCOL_FIELD = b'"protocol":"microcosm.graph.execution-evidence.v1"' + + +@pytest.mark.parametrize( + "payload,message", + [ + (b'{"protocol":"microcosm.graph.execution-input.v1"}', "evidence.v1 object"), + (b'["microcosm.graph.execution-evidence.v1"]', "evidence.v1 object"), + ( + b"{" + _OVERLAY_PROTOCOL_FIELD + b',"history":"a","history":"b"}', + "duplicate", + ), + (b"{" + _OVERLAY_PROTOCOL_FIELD + b',"history":NaN}', "finite JSON numbers"), + (b"{" + _OVERLAY_PROTOCOL_FIELD + b',"history":1e400}', "finite numbers"), + (b"{" + _OVERLAY_PROTOCOL_FIELD.replace(b"evidence", b"\xff") + b"}", "utf-8"), + ], + ids=[ + "another protocol", + "not an object", + "duplicate key", + "NaN literal", + "overflowing number", + "not UTF-8", + ], +) +def test_execution_overlay_reader_refuses_malformed_files(tmp_path, payload, message): + overlay_path = tmp_path / "graph.execution.json" + overlay_path.write_bytes(payload) + with pytest.raises(ValueError, match=message): + execution_overlay.load_execution_overlay(overlay_path) + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="requires POSIX FIFO") +def test_execution_overlay_reader_refuses_a_fifo_without_waiting_for_a_writer( + tmp_path, +): + overlay_path = tmp_path / "graph.execution.json" + os.mkfifo(overlay_path) + with pytest.raises(ValueError, match="regular file"): + execution_overlay.load_execution_overlay(overlay_path) + + +def test_execution_overlay_reader_refuses_a_file_over_the_byte_limit( + recorded, tmp_path, monkeypatch +): + _schema, overlay = recorded + overlay_path = tmp_path / "graph.execution.json" + payload = execution_overlay.execution_overlay_bytes(overlay) + overlay_path.write_bytes(payload) + monkeypatch.setattr(execution_overlay, "_MAX_OVERLAY_BYTES", len(payload) - 1) + with pytest.raises(ValueError, match="byte limit"): + execution_overlay.load_execution_overlay(overlay_path) + + +def test_execution_overlay_writer_refuses_a_value_without_the_overlay_protocol( + recorded, +): + _schema, overlay = recorded + another_protocol = {**overlay, "protocol": "microcosm.graph.execution-input.v1"} + for value in (another_protocol, [overlay]): + with pytest.raises(ValueError, match="evidence.v1 object"): + execution_overlay.execution_overlay_bytes(value) From 8d159a8c5653ad63affb1a5d2e39034f877db2ff Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:49:37 +0400 Subject: [PATCH 3/7] Write the UK execution overlay beside the Orrery artifact UK finalize collected the execution overlay and projected it straight into graph.orrery.json, so the overlay itself never reached disk. The collector is to project builds from the schema and the overlay, so finalize now also writes the overlay as graph.execution.json, using execution_overlay_bytes, and registers it as a build output like the other graph files: in the dataset manifest's outputs, in the evidence manifest of a refused build, and in the failure bundle. It is deliberately left out of the Orrery publication set (the preserved orrery-publication/ directory and orrery.upload.json). The emitter's file roles and the runs service's contract do not admit it yet. Apart from the new manifest entry, every existing file stays byte-identical. The overlay's content is already embedded in graph.orrery.json, which is registered and staged the same way, so the new file discloses nothing new. Refs #1294 Co-Authored-By: Claude Opus 5.5 --- .../1294-uk-execution-overlay-output.added.md | 1 + docs/orrery-adapter.md | 5 + docs/orrery-publication.md | 8 ++ .../build/uk_runtime/orrery_publication.py | 18 ++- .../uk/test_uk_orrery_publication.py | 126 +++++++++++++++++- 5 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1294-uk-execution-overlay-output.added.md diff --git a/changelog.d/1294-uk-execution-overlay-output.added.md b/changelog.d/1294-uk-execution-overlay-output.added.md new file mode 100644 index 0000000000..60cfe95773 --- /dev/null +++ b/changelog.d/1294-uk-execution-overlay-output.added.md @@ -0,0 +1 @@ +UK finalize now writes the execution overlay it projects as `graph.execution.json`, beside `graph.orrery.json`, and registers it as a build output: in the dataset manifest, in a refused build's evidence manifest and in a failed build's bundle. Projecting `graph.schema.json` with it reproduces `graph.orrery.json` byte for byte. It stays out of the Orrery publication set (`orrery-publication/` and `orrery.upload.json`), and every existing file keeps its bytes, apart from the new entry in the manifest that registers it. diff --git a/docs/orrery-adapter.md b/docs/orrery-adapter.md index b1bee81c8c..469ab3e36d 100644 --- a/docs/orrery-adapter.md +++ b/docs/orrery-adapter.md @@ -297,6 +297,11 @@ that case, pass the attempt's `graph.schema.json` and `graph-evidence/execution.evidence.json`, and the same store. The spine sidecar identifies its native index under the checkpoint directory; use that directory's `graph.schema.json` and `node-graph` store. Orrery JSON and HTML remain opt-in. +When UK finalize exports `graph.orrery.json` from recorded execution, it also +writes the overlay it projected as `graph.execution.json` and registers it as a +build output, so `graph.schema.json` and `graph.execution.json` reproduce the +export byte for byte without the store. The overlay is not part of the Orrery +publication set (see [UK graph publication](orrery-publication.md)). The final national schema, declaration and inventory all include the appended `uk.full.national.readback` operation. Earlier endpoint runs remain separate diff --git a/docs/orrery-publication.md b/docs/orrery-publication.md index b6b0a59a68..e073acd931 100644 --- a/docs/orrery-publication.md +++ b/docs/orrery-publication.md @@ -6,6 +6,14 @@ public-safe file inventory in the attempt directory, attempt publication before Hugging Face artifact staging, and register graph/evidence files and an `orrery.publication.json` receipt snapshot in the artifact manifest. +When execution was recorded, they also write the execution overlay that +`graph.orrery.json` embeds as `graph.execution.json` and register it in the +same manifest. A failed build's bundle carries it too. It is not copied to the +preserved `orrery-publication/` directory or listed in `orrery.upload.json`: +the emitter's file roles and the runs service's contract do not admit it yet. +Its content is already inside `graph.orrery.json`, so registering it discloses +nothing new. + Microcosm's metadata adapter starts one local emitter service with two independent workers: live telemetry and completed graph publication. The `microcosm-emitter` package owns sockets, authentication, persistence, retries and resource sampling. diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_publication.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_publication.py index 3cd1be5c4b..d3666f8537 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_publication.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_publication.py @@ -26,11 +26,13 @@ from microcosm.graph import ContentStore from microcosm.graph.canonical import canonical_json from microcosm.graph.evidence import collect_execution_evidence, load_run_evidence +from microcosm.graph.execution_overlay import execution_overlay_bytes from microcosm.graph.orrery import orrery_json_from_schema RECEIPT_NAME = "orrery.publication.json" INVENTORY_NAME = "orrery.upload.json" EVIDENCE_MANIFEST_NAME = "orrery.evidence-manifest.json" +EXECUTION_OVERLAY_NAME = "graph.execution.json" FAILURE_MARKER_NAME = "orrery.failure.json" FAILURE_BUNDLE_DIRECTORY = "orrery-failure" # How long a build waits for a first upload result after the durable enqueue. @@ -141,6 +143,18 @@ def finalize_graph( roles[run[field]["path"]] = role payload = orrery_json_from_schema(schema, execution=execution).encode() materialize_bytes(payload, output / "graph.orrery.json") + # The overlay is the projection's only input besides the schema, + # so a later projection can start from these two files. It is a + # registered build output but not part of the Orrery publication: + # the emitter's file roles and the runs service's contract do not + # admit it yet. + unpublished: list[str] = [] + if execution is not None: + materialize_bytes( + execution_overlay_bytes(execution), + output / EXECUTION_OVERLAY_NAME, + ) + unpublished.append(EXECUTION_OVERLAY_NAME) for url in _urls(json.loads(payload)): name = url.split("#", 1)[0] if re.fullmatch(r"upstream-[a-f0-9]{64}\.json", name): @@ -149,7 +163,7 @@ def finalize_graph( roles[name] = "upstream" # Keep exact sources outside the build's temporary directory and # outside HF's immutable receipt snapshot, including when offline. - names = list(roles) + names = [*roles, *unpublished] preserved = Path(args.attempt_evidence) / "orrery-publication" for name in roles: materialize_bytes((output / name).read_bytes(), preserved / name) @@ -169,7 +183,7 @@ def finalize_graph( raise RuntimeError("Local graph inventory service is unavailable.") materialize_bytes(canonical_json(inventory), preserved / INVENTORY_NAME) materialize_bytes(canonical_json(inventory), output / INVENTORY_NAME) - names = [*roles, INVENTORY_NAME] + names = [*roles, *unpublished, INVENTORY_NAME] receipt = { "version": 1, "publication_id": inventory["publication_id"], diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_publication.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_publication.py index d35c21ea5e..393e4040eb 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_publication.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_publication.py @@ -1,19 +1,36 @@ """UK exports and staging order, independent of the runs application's code.""" +import hashlib import json from argparse import Namespace +from pathlib import Path import pytest +import test_support.microcosm_build.uk_full_build_cli as full_build_support from microcosm.build.uk_runtime import ( full_build_cli, orrery_publication, rowwise_staging, ) from microcosm.build.uk_runtime.orrery_contract import save_uk_graph_schema -from microcosm.graph import Graph, Node, Owned, SourceRef, StructuralDelta +from microcosm.graph import ( + ContentStore, + Graph, + Node, + Owned, + SourceRef, + StructuralDelta, + collect_execution_evidence, + load_run_evidence, +) +from microcosm.graph.execution_overlay import load_execution_overlay +from microcosm.graph.orrery import orrery_json_from_schema from test_support.microcosm_build.telemetry import fake_publication_inventory +#: The execution overlay's file name, which the collector's inputs will name. +OVERLAY_NAME = "graph.execution.json" + def fixture(tmp_path, **options): out = tmp_path / "temporary" @@ -323,3 +340,110 @@ def publish_graph(self, directory, inventory, *, wait_seconds): assert (args.out / "graph.orrery.json").is_file() assert (args.out / "orrery.upload.json").is_file() assert (args.out / "orrery.evidence-manifest.json").is_file() + + +def synthetic_build_request(tmp_path, monkeypatch, failure=None): + """A synthetic dense build request whose run records execution.""" + pytest.importorskip("tables") + full_build_support.patch_certification(monkeypatch) + args = full_build_support.arguments(tmp_path) + return args, full_build_support.prepared(tmp_path, failure) + + +def synthetic_full_build(tmp_path, monkeypatch, failure=None): + """Run a synthetic dense build; return its arguments and exit status.""" + args, build = synthetic_build_request(tmp_path, monkeypatch, failure) + return args, full_build_support.cli.execute_full_build(build, args) + + +def test_execution_overlay_reprojects_to_the_exported_orrery_artifact( + tmp_path, monkeypatch +): + args, status = synthetic_full_build(tmp_path, monkeypatch) + assert status == 0 + schema = json.loads((args.out / "graph.schema.json").read_bytes()) + store = ContentStore(args.out / ".graph-store", create=False) + collected = collect_execution_evidence( + schema, + runs=load_run_evidence(args.out / "execution.evidence.json", store=store), + store=store, + ) + + overlay = load_execution_overlay(args.out / OVERLAY_NAME) + + assert overlay == collected + assert ( + orrery_json_from_schema(schema, execution=overlay).encode() + == (args.out / "graph.orrery.json").read_bytes() + ) + + +def test_execution_overlay_is_registered_but_not_published(tmp_path, monkeypatch): + args, status = synthetic_full_build(tmp_path, monkeypatch) + assert status == 0 + overlay_path = args.out / OVERLAY_NAME + manifest = json.loads( + (args.out / full_build_support.cli.MANIFEST_FILENAME).read_text() + ) + registered = manifest["outputs"][f"orrery/{OVERLAY_NAME}"] + assert Path(registered["path"]) == overlay_path + assert registered["sha256"] == hashlib.sha256(overlay_path.read_bytes()).hexdigest() + assert registered["bytes"] == overlay_path.stat().st_size + preserved = args.attempt_evidence / "orrery-publication" + inventory = json.loads((preserved / "orrery.upload.json").read_bytes()) + assert (preserved / "graph.orrery.json").is_file() + assert not (preserved / OVERLAY_NAME).exists() + assert OVERLAY_NAME not in {entry["name"] for entry in inventory["files"]} + + +def test_refused_build_registers_its_execution_overlay_in_its_evidence_record( + tmp_path, monkeypatch +): + args, status = synthetic_full_build( + tmp_path, monkeypatch, "uk_local_geography_ladder_post_calibration" + ) + assert status == 1 + evidence = json.loads( + (args.out / orrery_publication.EVIDENCE_MANIFEST_NAME).read_bytes() + ) + overlay_path = args.out / OVERLAY_NAME + assert evidence["orrery_publication"]["recorded_execution"] is True + assert evidence["outputs"][OVERLAY_NAME]["sha256"] == ( + hashlib.sha256(overlay_path.read_bytes()).hexdigest() + ) + + +def test_failed_build_keeps_its_execution_overlay_in_the_failure_bundle( + tmp_path, monkeypatch +): + args, build = synthetic_build_request(tmp_path, monkeypatch) + + def refuse_manifest(*arguments, **options): + raise RuntimeError("manifest projection failed") + + monkeypatch.setattr( + full_build_support.cli, "rowwise_candidate_manifest_from_graph", refuse_manifest + ) + with pytest.raises(RuntimeError, match="manifest projection failed"): + full_build_support.cli.execute_full_build(build, args) + bundle = args.attempt_evidence / orrery_publication.FAILURE_BUNDLE_DIRECTORY + evidence = json.loads( + (bundle / orrery_publication.EVIDENCE_MANIFEST_NAME).read_bytes() + ) + assert evidence["orrery_publication"]["recorded_execution"] is True + assert Path(evidence["outputs"][OVERLAY_NAME]["path"]) == bundle / OVERLAY_NAME + assert load_execution_overlay(bundle / OVERLAY_NAME)["phases"] + assert not (args.attempt_evidence / "orrery-publication" / OVERLAY_NAME).exists() + + +def test_build_without_recorded_execution_writes_no_execution_overlay( + tmp_path, monkeypatch +): + args = fixture(tmp_path) + monkeypatch.setattr(rowwise_staging, "_ACTIVE_EMITTER", None) + manifest = {"outputs": {}} + result = orrery_publication.finalize_graph(args, manifest=manifest) + assert result.receipt["recorded_execution"] is False + assert not (args.out / OVERLAY_NAME).exists() + assert OVERLAY_NAME not in result.files + assert f"orrery/{OVERLAY_NAME}" not in manifest["outputs"] From 50406fd59250f9bfa8705e6eafde7aba10a51776 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:07:30 +0400 Subject: [PATCH 4/7] Run the Tests workflow for pull requests into any base branch The pull_request trigger was filtered to branches: [main]. A stacked pull request targets another pull request's branch, so GitHub started no Tests run for it at all. The filter is removed. Pushes still run the workflow only for main. Nothing else in the workflow depends on the base branch. The concurrency group is keyed on the pull request number, and country selection reads the pull request's own changed files from the API. Pull requests from this repository's branches get the same token permissions and secrets whatever their base. New tests read the workflow's triggers. Pull requests into main, into another pull request's branch and into a nested branch name must all run it, and pushes must run it only for main. The agent guide and a changelog fragment say so. Fixes #1321 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 3 ++- ...-ci-pull-requests-into-any-base.changed.md | 1 + docs/agent-guide.md | 4 ++- .../shared/test_ci_integration_job.py | 26 +++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1321-ci-pull-requests-into-any-base.changed.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e7898bfb3..e5a2a415cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,8 +3,9 @@ name: Tests on: push: branches: [main] + # Pull requests into any base branch: a stacked pull request targets + # another pull request's branch and needs the same jobs. pull_request: - branches: [main] # Cancel outdated PR runs; give each main push its own independent group. concurrency: diff --git a/changelog.d/1321-ci-pull-requests-into-any-base.changed.md b/changelog.d/1321-ci-pull-requests-into-any-base.changed.md new file mode 100644 index 0000000000..40534bcbc0 --- /dev/null +++ b/changelog.d/1321-ci-pull-requests-into-any-base.changed.md @@ -0,0 +1 @@ +The Tests workflow (`.github/workflows/test.yml`) now runs for pull requests into any base branch, not only `main` (microcosm#1321). A stacked pull request, whose base is another pull request's branch, previously got no CI at all. Pushes still run it only for `main`, and the jobs, the country selection, the concurrency group and the secrets are unchanged. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index b14084800d..e98d73e8aa 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -41,7 +41,9 @@ or `uv sync --all-packages --all-extras`. To keep both countries set up, point PR CI (`.github/workflows/test.yml`) has `lint`, `engine-free`, `engine-us`, `engine-uk`, `integration-uk`, and `wheels` jobs, plus the -`select-countries` orchestration job. +`select-countries` orchestration job. It runs for pull requests into any base +branch, so a stacked pull request, whose base is another pull request's +branch, gets the same jobs; pushes run it only for `main`. `tools/ci_test_plan.py` is the only authority for test-directory ownership, CI job assignment, country ownership, US timing-report categories, and changed-path country selection. Its `TEST_GROUPS` registry defines every valid diff --git a/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py b/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py index bcaac635a3..7dc8e1b0a7 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest import yaml from test_support.paths import paths_for @@ -71,6 +72,31 @@ def test_integration_job_is_uk_selected_and_read_only() -> None: assert "run: bash tools/run_integration_tests.sh" in integration +def _workflow_triggers() -> dict: + workflow = yaml.safe_load(_TEST_WORKFLOW.read_text(encoding="utf-8")) + # PyYAML follows YAML 1.1, which reads the bare key ``on`` as boolean true. + return workflow[True] + + +@pytest.mark.parametrize( + "base_branch", + ["main", "orrery-projection-without-store", "stacked/parent-branch"], +) +def test_pull_requests_into_any_base_branch_run_the_tests_workflow( + base_branch: str, +) -> None: + """A stacked pull request targets another pull request's branch, not main, + and must get the same jobs.""" + branch_filters = _workflow_triggers()["pull_request"] or {} + + assert base_branch in branch_filters.get("branches", [base_branch]) + assert base_branch not in branch_filters.get("branches-ignore", []) + + +def test_only_pushes_to_main_run_the_tests_workflow() -> None: + assert _workflow_triggers()["push"] == {"branches": ["main"]} + + def test_every_workflow_job_caps_its_runtime() -> None: """A hung job must not hold an org-shared runner for GitHub's 6-hour default.""" jobs = yaml.safe_load(_TEST_WORKFLOW.read_text(encoding="utf-8"))["jobs"] From b701a5e4937bed6a39b54514f0021a10afa393c0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:48:32 +0400 Subject: [PATCH 5/7] Assert the pull_request trigger has no base-branch filter The previous test checked three literal branch names against the trigger's branches and branches-ignore lists. GitHub matches those entries as glob patterns, so a membership check proves nothing about other bases: a filter listing exactly those three names passed it, although pull requests into any other base would get no run, and a filter of '**', which runs for every base, failed it. The intended design is no base-branch filter at all, so the test now asserts that the pull_request trigger has neither a branches nor a branches-ignore key. _workflow_triggers is typed as dict[str, Any]. Co-Authored-By: Claude Opus 5.5 --- .../shared/test_ci_integration_job.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py b/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py index 7dc8e1b0a7..70caa25491 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py @@ -2,7 +2,8 @@ from __future__ import annotations -import pytest +from typing import Any + import yaml from test_support.paths import paths_for @@ -72,25 +73,24 @@ def test_integration_job_is_uk_selected_and_read_only() -> None: assert "run: bash tools/run_integration_tests.sh" in integration -def _workflow_triggers() -> dict: +def _workflow_triggers() -> dict[str, Any]: workflow = yaml.safe_load(_TEST_WORKFLOW.read_text(encoding="utf-8")) # PyYAML follows YAML 1.1, which reads the bare key ``on`` as boolean true. return workflow[True] -@pytest.mark.parametrize( - "base_branch", - ["main", "orrery-projection-without-store", "stacked/parent-branch"], -) -def test_pull_requests_into_any_base_branch_run_the_tests_workflow( - base_branch: str, -) -> None: +def test_pull_requests_into_any_base_branch_run_the_tests_workflow() -> None: """A stacked pull request targets another pull request's branch, not main, - and must get the same jobs.""" - branch_filters = _workflow_triggers()["pull_request"] or {} + and must get the same jobs. + + GitHub matches ``branches`` and ``branches-ignore`` entries as glob + patterns, so checking particular branch names against them proves nothing + about other bases. The trigger must have no base-branch filter at all. + """ + pull_request_trigger = _workflow_triggers()["pull_request"] or {} - assert base_branch in branch_filters.get("branches", [base_branch]) - assert base_branch not in branch_filters.get("branches-ignore", []) + base_branch_filters = {"branches", "branches-ignore"} & pull_request_trigger.keys() + assert sorted(base_branch_filters) == [] def test_only_pushes_to_main_run_the_tests_workflow() -> None: From 81650f002d2525b544ac058095371e1db439da2e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:48:32 +0400 Subject: [PATCH 6/7] Note the runner cost of running CI for pull requests into any base Every push to a pull request's branch now runs the full Tests workflow on the organization's shared runners, whatever the base, so merging one change up a stack of N pull requests starts N full runs. The agent guide says so. Co-Authored-By: Claude Opus 5.5 --- docs/agent-guide.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/agent-guide.md b/docs/agent-guide.md index e98d73e8aa..b6b043db82 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -43,7 +43,11 @@ PR CI (`.github/workflows/test.yml`) has `lint`, `engine-free`, `engine-us`, `engine-uk`, `integration-uk`, and `wheels` jobs, plus the `select-countries` orchestration job. It runs for pull requests into any base branch, so a stacked pull request, whose base is another pull request's -branch, gets the same jobs; pushes run it only for `main`. +branch, gets the same jobs; pushes run it only for `main`. That costs runner +time: every push to a pull request's branch, whatever its base, runs the full +workflow on the organization's shared runners, with the jobs and country +selection a pull request into `main` would get. Merging one change up a stack +of N pull requests therefore starts N full runs. `tools/ci_test_plan.py` is the only authority for test-directory ownership, CI job assignment, country ownership, US timing-report categories, and changed-path country selection. Its `TEST_GROUPS` registry defines every valid From 4ced66de0edf6c15f609b17b79c6938e60c19c2c Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Tue, 20 Oct 2026 22:02:09 +0400 Subject: [PATCH 7/7] Run the Tests workflow only for pull requests into main again This branch carried #1322 through three merges of its branch (`ci-pull-requests-into-any-base`). #1322 removed the `branches: [main]` filter from the Tests workflow so stacked pull requests would get CI. That is not wanted: CI runs only for pull requests into `main` on purpose, to limit the number of simultaneous runs on the organization's shared runners. #1322 is being closed. This restores the four files #1322 changed to their versions on `main` (`a90eec3cb`), which is also this branch's merge base with `main`: - `.github/workflows/test.yml`: the `pull_request` trigger filters on `main` again; - `docs/agent-guide.md`: the CI paragraph no longer says any base runs; - `packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py`: #1322's two trigger tests are removed; - `changelog.d/1321-ci-pull-requests-into-any-base.changed.md`: deleted. The branch's own commits are not rewritten, so `8d159a8`, which microcosm-collector#9 records as its source, stays in the history. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 3 +-- ...-ci-pull-requests-into-any-base.changed.md | 1 - docs/agent-guide.md | 8 +----- .../shared/test_ci_integration_job.py | 26 ------------------- 4 files changed, 2 insertions(+), 36 deletions(-) delete mode 100644 changelog.d/1321-ci-pull-requests-into-any-base.changed.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e5a2a415cd..1e7898bfb3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,9 +3,8 @@ name: Tests on: push: branches: [main] - # Pull requests into any base branch: a stacked pull request targets - # another pull request's branch and needs the same jobs. pull_request: + branches: [main] # Cancel outdated PR runs; give each main push its own independent group. concurrency: diff --git a/changelog.d/1321-ci-pull-requests-into-any-base.changed.md b/changelog.d/1321-ci-pull-requests-into-any-base.changed.md deleted file mode 100644 index 40534bcbc0..0000000000 --- a/changelog.d/1321-ci-pull-requests-into-any-base.changed.md +++ /dev/null @@ -1 +0,0 @@ -The Tests workflow (`.github/workflows/test.yml`) now runs for pull requests into any base branch, not only `main` (microcosm#1321). A stacked pull request, whose base is another pull request's branch, previously got no CI at all. Pushes still run it only for `main`, and the jobs, the country selection, the concurrency group and the secrets are unchanged. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index b6b043db82..b14084800d 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -41,13 +41,7 @@ or `uv sync --all-packages --all-extras`. To keep both countries set up, point PR CI (`.github/workflows/test.yml`) has `lint`, `engine-free`, `engine-us`, `engine-uk`, `integration-uk`, and `wheels` jobs, plus the -`select-countries` orchestration job. It runs for pull requests into any base -branch, so a stacked pull request, whose base is another pull request's -branch, gets the same jobs; pushes run it only for `main`. That costs runner -time: every push to a pull request's branch, whatever its base, runs the full -workflow on the organization's shared runners, with the jobs and country -selection a pull request into `main` would get. Merging one change up a stack -of N pull requests therefore starts N full runs. +`select-countries` orchestration job. `tools/ci_test_plan.py` is the only authority for test-directory ownership, CI job assignment, country ownership, US timing-report categories, and changed-path country selection. Its `TEST_GROUPS` registry defines every valid diff --git a/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py b/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py index 70caa25491..bcaac635a3 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Any - import yaml from test_support.paths import paths_for @@ -73,30 +71,6 @@ def test_integration_job_is_uk_selected_and_read_only() -> None: assert "run: bash tools/run_integration_tests.sh" in integration -def _workflow_triggers() -> dict[str, Any]: - workflow = yaml.safe_load(_TEST_WORKFLOW.read_text(encoding="utf-8")) - # PyYAML follows YAML 1.1, which reads the bare key ``on`` as boolean true. - return workflow[True] - - -def test_pull_requests_into_any_base_branch_run_the_tests_workflow() -> None: - """A stacked pull request targets another pull request's branch, not main, - and must get the same jobs. - - GitHub matches ``branches`` and ``branches-ignore`` entries as glob - patterns, so checking particular branch names against them proves nothing - about other bases. The trigger must have no base-branch filter at all. - """ - pull_request_trigger = _workflow_triggers()["pull_request"] or {} - - base_branch_filters = {"branches", "branches-ignore"} & pull_request_trigger.keys() - assert sorted(base_branch_filters) == [] - - -def test_only_pushes_to_main_run_the_tests_workflow() -> None: - assert _workflow_triggers()["push"] == {"branches": ["main"]} - - def test_every_workflow_job_caps_its_runtime() -> None: """A hung job must not hold an org-shared runner for GitHub's 6-hour default.""" jobs = yaml.safe_load(_TEST_WORKFLOW.read_text(encoding="utf-8"))["jobs"]