From 4f5da6c6802f5309d947d54a3a4fb9b935de7445 Mon Sep 17 00:00:00 2001 From: Justin Scott Date: Sun, 23 Aug 2026 17:05:41 -0400 Subject: [PATCH] feat(routines): add fixed-dimension member identity --- SCHEMA.md | 30 ++-- routines/README.md | 13 +- routines/schemas/interface.schema.json | 59 ++++++- tools/lint/routine_schemas.py | 28 +++- .../fixtures/routine_schemas/interface.json | 5 +- tools/lint/tests/test_routine_schemas.py | 146 +++++++++++++++++- 6 files changed, 249 insertions(+), 32 deletions(-) diff --git a/SCHEMA.md b/SCHEMA.md index 01d0157..f38d68f 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -102,7 +102,7 @@ This schema set governs six JSON Schema Draft 2020-12 resources: |---|---| | `routines/schemas/common.schema.json` | `https://open-control-library.example/schemas/routine-common-v1.json` | | `routines/schemas/class-manifest.schema.json` | `https://open-control-library.example/schemas/routine-class-manifest-v1.json` | -| `routines/schemas/interface.schema.json` | `https://open-control-library.example/schemas/routine-interface-v2.json` | +| `routines/schemas/interface.schema.json` | `https://open-control-library.example/schemas/routine-interface-v3.json` | | `routines/schemas/specialization.schema.json` | `https://open-control-library.example/schemas/routine-specialization-v1.json` | | `routines/schemas/routine-semantic-profile.schema.json` | `https://open-control-library.example/schemas/routine-semantic-profile-v1.json` | | `routines/schemas/routine-derivation-manifest.schema.json` | `https://open-control-library.example/schemas/routine-derivation-manifest-v1.json` | @@ -112,8 +112,8 @@ Each resource declares fragments or the six absolute IDs above and resolve from an in-memory registry. Validation performs no network or filesystem retrieval for schema references. Objects are closed unless stated otherwise. Semantic-only definitions belong to -the semantic-profile resource; `routine-common-v1` remains the existing routine -class/interface contract. +the semantic-profile resource. `routine-common-v1` retains its unchanged reusable +definitions; the interface v3 resource defines its revised dimension form locally. Canonical class IDs have the form `G36-05-(01..22)-`. Scope IDs are invalid @@ -152,7 +152,7 @@ share one non-root class directory and end in `interface.json`, `specialization.schema.json`, and `specialization.json`, respectively. This is an artifact-location contract, not a production source-to-class mapping. -#### Interfaces (`cxf-library/routine-interface/v2`) +#### Interfaces (`cxf-library/routine-interface/v3`) An interface has exactly `schema`, `canonical_id`, `revision`, `types`, `dimensions`, `parameters`, and `connectors`. Types and enums are local to that @@ -167,9 +167,15 @@ values use the stable member IDs; no integer lowering code is assigned. A type use is either primitive or a reference to a local named type. A shape is either scalar or an array with an ordered list of one or two dimension IDs. -Dimensions have unique IDs. Their extent is either a positive fixed integer or -a reference to a scalar Integer parameter. Rank greater than two, zero extents, -ragged matrices, and arithmetic dimension expressions are invalid. +Dimensions have unique IDs. A fixed dimension declares a positive integer +extent and a nonempty ordered `members` list whose count MUST equal that extent. +A parameter-driven dimension references a scalar Integer parameter and MUST NOT +declare canonical members; `routine-specialization/v1` owns its member list. +Dimension members are authored stable identities, not identities derived from +array ordinals. Authored list order is contract order. Within one interface and +specialization pair, member IDs MUST be unique across every fixed and +parameter-driven dimension. Rank greater than two, zero extents, ragged +matrices, and arithmetic dimension expressions are invalid. Parameters have unique IDs, a type use, shape, `fixed` or `configurable` configurability, an optional typed default, and optional numeric minimum and @@ -192,15 +198,17 @@ or resolve optional branches. A specialization input has exactly `schema`, `canonical_id`, `revision`, `parameters`, and `members`. `parameters` is an ordered list of unique parameter IDs and JSON values. `members` binds each parameter-driven dimension ID to a -nonempty ordered list of globally unique stable member IDs. +nonempty ordered list of stable member IDs. Fixed dimensions are invalid targets. +These IDs MUST be unique across the specialization's member records and against +the canonical members of every fixed dimension in the paired interface. The interface and specialization canonical ID and revision MUST agree with the class manifest. Specialization checks parameter existence, fixed-parameter override rejection, required configurable assignments, primitive and enum value compatibility, numeric bounds, concrete dimension extents, rectangular -rank-one and rank-two values, and stable-member count. All numeric values MUST -be finite. A parameter-driven dimension resolves only from a positive Integer -effective value. +rank-one and rank-two values, and parameter-driven member count. All numeric +values MUST be finite. A parameter-driven dimension resolves only from a +positive Integer effective value, and its member count MUST equal that value. Specialization is input only. It contains no connector bindings, point IDs, resolved connector set, source map, generated CXF, runtime state, engine diff --git a/routines/README.md b/routines/README.md index 678b6f6..9feae52 100644 --- a/routines/README.md +++ b/routines/README.md @@ -76,10 +76,15 @@ cargo run --manifest-path tools/verify/Cargo.toml -- --routines Canonical IDs name parameterized engineering classes, never fixed parameter variants or source locations. Local types and enums belong to one interface. The schemas cover scalar and rank-one/rank-two typed values, -parameter-controlled dimensions, stable repeated-member IDs, and -parameter-only optional-connector guards. They do not evaluate guards or -define production connector bindings, source mapping instances, -specializations, generated deployments, or executable CXF. +fixed and parameter-controlled dimensions, stable repeated-member IDs, and +parameter-only optional-connector guards. A fixed dimension owns an ordered +canonical member list in interface v3 whose count equals its extent. A +parameter-driven dimension has no canonical member list; specialization v1 owns +its ordered members. Member IDs are authored stable identities rather than +array ordinals and are unique across all dimensions in an interface and +specialization pair. The schemas do not evaluate guards or define production +connector bindings, source mapping instances, specializations, generated +deployments, or executable CXF. The semantic and derivation schemas are exercised only by synthetic fixtures under `tools/lint/tests/fixtures/routine_semantics/`. Validation is local and diff --git a/routines/schemas/interface.schema.json b/routines/schemas/interface.schema.json index 58c5617..a0915b8 100644 --- a/routines/schemas/interface.schema.json +++ b/routines/schemas/interface.schema.json @@ -1,12 +1,65 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://open-control-library.example/schemas/routine-interface-v2.json", + "$id": "https://open-control-library.example/schemas/routine-interface-v3.json", "title": "Open Control Library canonical routine interface", + "$defs": { + "dimension": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["id", "extent", "members"], + "properties": { + "id": { + "$ref": "https://open-control-library.example/schemas/routine-common-v1.json#/$defs/localId" + }, + "extent": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "value"], + "properties": { + "kind": {"const": "fixed"}, + "value": {"type": "integer", "minimum": 1} + } + }, + "members": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "https://open-control-library.example/schemas/routine-common-v1.json#/$defs/stableMemberId" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "extent"], + "properties": { + "id": { + "$ref": "https://open-control-library.example/schemas/routine-common-v1.json#/$defs/localId" + }, + "extent": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "parameter"], + "properties": { + "kind": {"const": "parameter"}, + "parameter": { + "$ref": "https://open-control-library.example/schemas/routine-common-v1.json#/$defs/localId" + } + } + } + } + } + ] + } + }, "type": "object", "additionalProperties": false, "required": ["schema", "canonical_id", "revision", "types", "dimensions", "parameters", "connectors"], "properties": { - "schema": {"const": "cxf-library/routine-interface/v2"}, + "schema": {"const": "cxf-library/routine-interface/v3"}, "canonical_id": { "$ref": "https://open-control-library.example/schemas/routine-common-v1.json#/$defs/canonicalId" }, @@ -22,7 +75,7 @@ "dimensions": { "type": "array", "items": { - "$ref": "https://open-control-library.example/schemas/routine-common-v1.json#/$defs/dimension" + "$ref": "#/$defs/dimension" } }, "parameters": { diff --git a/tools/lint/routine_schemas.py b/tools/lint/routine_schemas.py index d259471..e7bc030 100644 --- a/tools/lint/routine_schemas.py +++ b/tools/lint/routine_schemas.py @@ -25,7 +25,7 @@ CLASS_MANIFEST_ID = ( "https://open-control-library.example/schemas/routine-class-manifest-v1.json" ) -INTERFACE_ID = "https://open-control-library.example/schemas/routine-interface-v2.json" +INTERFACE_ID = "https://open-control-library.example/schemas/routine-interface-v3.json" SPECIALIZATION_ID = ( "https://open-control-library.example/schemas/routine-specialization-v1.json" ) @@ -650,9 +650,24 @@ def _check_interface_and_specialization(interface, specialization, errors): f"{interface_label}: $.parameters[{index}].constraints minimum exceeds maximum" ) + all_member_locations = {} for index, dimension in enumerate(interface["dimensions"]): extent = dimension["extent"] - if extent["kind"] != "parameter": + if extent["kind"] == "fixed": + members = dimension["members"] + if len(members) != extent["value"]: + errors.append( + f"{interface_label}: $.dimensions[{index}].members: expected {extent['value']} members, found {len(members)}" + ) + for member_index, member_id in enumerate(members): + location = f"{interface_label}: $.dimensions[{index}].members[{member_index}]" + first_location = all_member_locations.get(member_id) + if first_location is not None: + errors.append( + f"{location}: duplicate stable member {member_id!r}; first used at {first_location}" + ) + else: + all_member_locations[member_id] = location continue parameter_id = extent["parameter"] parameter = parameters.get(parameter_id) @@ -789,7 +804,6 @@ def _check_interface_and_specialization(interface, specialization, errors): errors.append( f"{specialization_label}: parameter-driven dimension {missing!r} requires stable members" ) - all_member_ids = {} for index, record in enumerate(specialization["members"]): dimension_id = record["dimension"] dimension = dimensions.get(dimension_id) @@ -807,12 +821,14 @@ def _check_interface_and_specialization(interface, specialization, errors): f"{specialization_label}: $.members[{index}].members: expected {expected} members, found {len(record['members'])}" ) for member_index, member_id in enumerate(record["members"]): - if member_id in all_member_ids: + location = f"{specialization_label}: $.members[{index}].members[{member_index}]" + first_location = all_member_locations.get(member_id) + if first_location is not None: errors.append( - f"{specialization_label}: $.members[{index}].members[{member_index}]: duplicate stable member {member_id!r}" + f"{location}: duplicate stable member {member_id!r}; first used at {first_location}" ) else: - all_member_ids[member_id] = (index, member_index) + all_member_locations[member_id] = location def _check_cross_document(manifest, interface, specialization, errors): diff --git a/tools/lint/tests/fixtures/routine_schemas/interface.json b/tools/lint/tests/fixtures/routine_schemas/interface.json index c2396e8..0ce5fe0 100644 --- a/tools/lint/tests/fixtures/routine_schemas/interface.json +++ b/tools/lint/tests/fixtures/routine_schemas/interface.json @@ -1,5 +1,5 @@ { - "schema": "cxf-library/routine-interface/v2", + "schema": "cxf-library/routine-interface/v3", "canonical_id": "G36-05-16-SYNTHETIC-SCHEMA-TEST", "revision": 1, "types": [ @@ -31,7 +31,8 @@ "dimensions": [ { "id": "fixed_pair", - "extent": {"kind": "fixed", "value": 2} + "extent": {"kind": "fixed", "value": 2}, + "members": ["primary", "secondary"] }, { "id": "zones", diff --git a/tools/lint/tests/test_routine_schemas.py b/tools/lint/tests/test_routine_schemas.py index 038a372..3e4fff2 100644 --- a/tools/lint/tests/test_routine_schemas.py +++ b/tools/lint/tests/test_routine_schemas.py @@ -78,6 +78,24 @@ def test_production_schemas_and_fixture_set_are_clean_and_repeatable(self): "routine schema lint: 6 schemas, 3 synthetic fixtures OK\n", ) + def test_interface_v3_identity_and_fixed_member_order_are_exact(self): + schema = self.read_json("routines/schemas/interface.schema.json") + interface = self.read_json(FIXTURE_FILES[1]) + expected_id = ( + "https://open-control-library.example/schemas/routine-interface-v3.json" + ) + self.assertEqual(routine_schemas.INTERFACE_ID, expected_id) + self.assertEqual(schema["$id"], expected_id) + self.assertEqual( + schema["properties"]["schema"]["const"], + "cxf-library/routine-interface/v3", + ) + self.assertEqual(interface["schema"], "cxf-library/routine-interface/v3") + self.assertEqual( + interface["dimensions"][0]["members"], ["primary", "secondary"] + ) + self.assertEqual(routine_schemas.validate(self.root), []) + def test_json_loader_rejects_malformed_duplicates_and_nonfinite_numbers(self): path = self.root / FIXTURE_FILES[2] original = path.read_bytes() @@ -340,6 +358,118 @@ def test_dimension_contract_rejects_wrong_parameter_zero_extent_and_rank(self): ) self.assert_error("is not valid under any of the given schemas") + def test_fixed_dimension_requires_members_and_matching_count(self): + interface_path = FIXTURE_FILES[1] + + self.mutate( + interface_path, + lambda value: value["dimensions"][0].pop("members"), + ) + errors = self.assert_error("$.dimensions[0]:") + self.assertTrue( + any("is not valid under any of the given schemas" in error for error in errors) + ) + self.restore(interface_path) + + self.mutate( + interface_path, + lambda value: value["dimensions"][0].update(members=["primary"]), + ) + self.assert_error("$.dimensions[0].members: expected 2 members, found 1") + + def test_fixed_dimension_member_ids_are_globally_unique(self): + interface_path = FIXTURE_FILES[1] + specialization_path = FIXTURE_FILES[2] + + self.mutate( + interface_path, + lambda value: value["dimensions"][0].update( + members=["primary", "primary"] + ), + ) + self.assert_error( + "$.dimensions[0].members[1]: duplicate stable member 'primary'" + ) + self.restore(interface_path) + + self.mutate( + interface_path, + lambda value: value["dimensions"].append( + { + "id": "other_pair", + "extent": {"kind": "fixed", "value": 2}, + "members": ["tertiary", "primary"], + } + ), + ) + self.assert_error( + "$.dimensions[2].members[1]: duplicate stable member 'primary'" + ) + self.restore(interface_path) + + self.mutate( + specialization_path, + lambda value: value["members"][0]["members"].__setitem__(0, "primary"), + ) + self.assert_error( + "$.members[0].members[0]: duplicate stable member 'primary'" + ) + + def test_fixed_member_syntax_rejects_malformed_and_numeric_ids(self): + interface_path = FIXTURE_FILES[1] + original = (self.root / interface_path).read_bytes() + for member_id in ("1", "bad_member"): + with self.subTest(member_id=member_id): + self.mutate( + interface_path, + lambda value: value["dimensions"][0]["members"].__setitem__( + 0, member_id + ), + ) + errors = self.assert_error("$.dimensions[0]:") + self.assertTrue( + any( + "is not valid under any of the given schemas" in error + for error in errors + ) + ) + (self.root / interface_path).write_bytes(original) + + self.mutate( + interface_path, + lambda value: value["dimensions"][0].update( + members=["primary2", "secondary-2"] + ), + ) + self.assertEqual(routine_schemas.validate(self.root), []) + + def test_dimension_member_ownership_is_closed(self): + interface_path = FIXTURE_FILES[1] + specialization_path = FIXTURE_FILES[2] + + self.mutate( + interface_path, + lambda value: value["dimensions"][1].update(members=["north-zone"]), + ) + errors = self.assert_error("$.dimensions[1]:") + self.assertTrue( + any("is not valid under any of the given schemas" in error for error in errors) + ) + self.restore(interface_path) + + self.mutate( + specialization_path, + lambda value: value["members"][0].update(dimension="fixed_pair"), + ) + self.assert_error("dimension 'fixed_pair' is not parameter-driven") + self.restore(specialization_path) + + self.mutate( + specialization_path, + lambda value: value.update(members=[]), + ) + self.assert_error("parameter-driven dimension 'zones' requires stable members") + def test_guards_reject_bad_operators_runtime_operands_and_type_errors(self): interface_path = FIXTURE_FILES[1] original = (self.root / interface_path).read_bytes() @@ -558,13 +688,17 @@ def introduce_errors(value): } self.mutate(interface_path, introduce_errors) - output = io.StringIO() - with contextlib.redirect_stdout(output): - result = routine_schemas.main(self.root) - self.assertEqual(result, 1) - lines = output.getvalue().splitlines() + outputs = [] + for _ in range(2): + output = io.StringIO() + with contextlib.redirect_stdout(output): + result = routine_schemas.main(self.root) + self.assertEqual(result, 1) + outputs.append(output.getvalue()) + self.assertEqual(outputs[0], outputs[1]) + lines = outputs[0].splitlines() self.assertEqual(lines, sorted(lines)) - self.assertNotIn("Traceback", output.getvalue()) + self.assertNotIn("Traceback", outputs[0]) output = io.StringIO() with contextlib.redirect_stdout(output):