From 13d0619a2549e9488fae7da6c774a345d491dd4a Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Fri, 12 Jun 2026 21:02:46 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20HC-121=20=E2=80=94=20dogfooding:=20?= =?UTF-8?q?the=20real=20Ontology=20examcalc=20package=20via=20IR=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First artifact of the adoption path: the hand-written examcalc DomainOntologyPackage (Ontology repo, 13 classes / 8 relations / 4 policies / 7-state machine, 240 lines of YAML) modeled as examcalc.{hc,hcs} and regenerated by a consumer-side adapter (backend.py) from the emitted IR v2. CI compares the result semantically against a verbatim copy of the original — it matched on the first complete run. - Kind defaults via selectors (.entity/.command/...) carry what the YAML restates per entry; per-node rules hold only specifics. - approvalStatus is a @stage context, not an edited field: approving the package is a one-node hypercode diff. - Contracts gate a document that previously had none (card_min >= 0, required domain/range/text). - All schema knowledge (envelope, key spelling, comma-joined list encoding) stays in the adapter — Hypercode never learns the ontology schema (DOCS/Backends.md rule, now demonstrated). The honest finding: size parity for one package (201 vs 212 meaningful lines) — compression starts at the second package via an @imported shared baseline. DOCS/Dogfooding.md starts the friction log (F1 no list values, F2 single-typed contracts, F3 synthetic sibling ids, F4 id-quoting noise, F5 flat property names); remaining HC-121 scope (ontologyc import step, Hyperprompt exercise) recorded in the workplan. Co-Authored-By: Claude Fable 5 --- .github/workflows/swift.yml | 7 +- DOCS/Dogfooding.md | 67 +++++ Examples/ontology-backend/README.md | 97 +++++++ Examples/ontology-backend/backend.py | 221 ++++++++++++++++ Examples/ontology-backend/examcalc.hc | 42 +++ Examples/ontology-backend/examcalc.hcs | 223 ++++++++++++++++ .../expected/domain-ontology-package.yaml | 240 ++++++++++++++++++ workplan.md | 2 +- 8 files changed, 897 insertions(+), 2 deletions(-) create mode 100644 DOCS/Dogfooding.md create mode 100644 Examples/ontology-backend/README.md create mode 100644 Examples/ontology-backend/backend.py create mode 100644 Examples/ontology-backend/examcalc.hc create mode 100644 Examples/ontology-backend/examcalc.hcs create mode 100644 Examples/ontology-backend/expected/domain-ontology-package.yaml diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index d43ae68..5c5665e 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -35,4 +35,9 @@ jobs: run: python3 Examples/codegen-demo/check.py - name: Kustomize comparison — metrics + all tenant×env targets validate - run: python3 Examples/kustomize-comparison/metrics.py --check \ No newline at end of file + run: python3 Examples/kustomize-comparison/metrics.py --check + + - name: Ontology backend — regenerated package matches the original + run: | + python3 -m pip install --quiet pyyaml + python3 Examples/ontology-backend/backend.py --check \ No newline at end of file diff --git a/DOCS/Dogfooding.md b/DOCS/Dogfooding.md new file mode 100644 index 0000000..20bf055 --- /dev/null +++ b/DOCS/Dogfooding.md @@ -0,0 +1,67 @@ +# Dogfooding Log (HC-121) + +Hypercode adopted for real artifacts from its own ecosystem. Each entry +records what was modeled, what worked, and — the actual point — what hurt. +Friction items are numbered (F1…) and feed the workplan. + +## Entry 1 — Ontology `examcalc` package (2026-06-12) + +**What:** the real `DomainOntologyPackage` for `examcalc` (Ontology repo, +`SPECS/ontology/packages/examcalc/`) — 13 classes, 8 relations, 4 policies, +a 7-state machine — modeled as +[`Examples/ontology-backend/examcalc.{hc,hcs}`](../Examples/ontology-backend/) +with a consumer-side adapter regenerating the YAML from IR v2. CI compares +the result semantically against a verbatim copy of the original; it matched +on the first complete run. + +### What worked + +- **Selector defaults are the real product.** Four kind rules + (`.entity`/`.capability`/`.command`/`.event`) plus type-level defaults for + `Relation` and `Policy` carry everything the YAML restates per entry. The + per-node rules contain only what is actually specific. +- **`@stage[approved]`** turns `approvalStatus` from an edited field into a + resolved context; `hypercode diff` shows package approval as exactly one + affected node. +- **Contracts on a document that had none:** `card_min: int >= 0`, required + `domain`/`range`/`text` — `validate` now gates edits that previously went + straight to the consumer compiler. +- **The Backends.md boundary held.** Every piece of schema knowledge + (envelope, key spelling, list encoding) fit naturally in the adapter; + nothing leaked into core. + +### Friction + +- **F1 — no list values in core.** `implements`, `appliesTo`, `states`, + `oneOf` ranges and the compatibility lists travel as comma-joined strings; + the split convention is an undocumented contract between sheet and + backend. Tolerable at this size, but it is the first thing a second + consumer would re-invent differently. *Candidate: list scalars as a core + extension or a sanctioned dialect layer (M9 discussion; conflicts with + "core stays minimal" — needs a decision, not a default).* +- **F2 — contract types are single-typed.** The schema's + `cardinality.max: int | "*"` is inexpressible; `card_max` ships + unconstrained. *Candidate: union types or value-pattern constraints in the + contract grammar.* +- **F3 — synthetic sibling ids.** Same-type siblings (the five `Transition` + nodes) need invented ids (`#start`, `#verify`, …) purely to be + addressable. Honest cost of the anchor model; the invented names did turn + out useful in `explain`/diff output. +- **F4 — id-selector quoting noise.** Every per-node rule reads + `'#Exam':` — the quotes (because bare `#` opens a comment) are the most + common syntax error while writing the sheet. +- **F5 — flat property names.** Nested YAML keys (`metadata.id`, + `cardinality.min`) flatten to `package_id`, `card_min`; the mapping lives + in the backend. Correct per the layering rules, but it means the sheet and + the target document drift vocabularies — provenance bridges it, a naming + convention would help. + +### Verdict + +Parity on size for one package (201 vs 212 meaningful lines), clear wins on +defaults, lifecycle-as-context, diff, contracts and provenance. The +compression story starts at the second package, when the kind defaults and +contracts move to a shared `@import`ed baseline. Next adoption step +(remaining scope of HC-121): an `import-hypercode` step inside `ontologyc` +itself, consuming the IR the way `backend.py` does, and the same exercise +for a Hyperprompt configuration. diff --git a/Examples/ontology-backend/README.md b/Examples/ontology-backend/README.md new file mode 100644 index 0000000..12a258c --- /dev/null +++ b/Examples/ontology-backend/README.md @@ -0,0 +1,97 @@ +# HC-121 — Dogfooding: a real Ontology package through the IR + +The worked example of [DOCS/Backends.md](../../DOCS/Backends.md), runnable. +The **real** `examcalc` DomainOntologyPackage from the Ontology repo — +13 classes, 8 relations, 4 policies, a 7-state machine, 240 lines of +hand-written YAML — described as `examcalc.{hc,hcs}` and regenerated through +the resolved IR by a consumer-side adapter: + +``` +examcalc.hc + examcalc.hcs ──▶ hypercode emit (IR v2) ──▶ backend.py ──▶ DomainOntologyPackage YAML + │ + semantically compared in CI against + expected/ — a verbatim copy of the + Ontology repo's hand-written file +``` + +```console +$ python3 backend.py --check +generated DomainOntologyPackage is semantically identical to the Ontology repo original (240 lines of YAML) +``` + +All schema knowledge — envelope constants, key names, the comma-joined list +encoding — lives in [`backend.py`](backend.py), per the Backends rule: +*Hypercode never learns the ontology schema*. + +## Where the cascade earns its keep + +The YAML repeats per entry what the sheet states once per **kind**: + +```hcs +.entity: + extends: "sg:DomainEntity" # covers 7 classes + +.command: + extends: "sg:Command" # covers 4 + +Relation: + card_min: 0 # covers 5 of 8 relations; + card_max: "*" # the three 1..1 pairs override + +Policy: + extends: "sg:Policy" + enforceability: "runtime" # covers all 4 +``` + +And every derived value stays explainable: + +```console +$ hypercode explain examcalc.hc --hcs examcalc.hcs "'#ExamSession'" extends +Node: Package#examcalc > Classes > Class.entity#ExamSession + extends + WINNER .entity { value: sg:DomainEntity } + file: examcalc.hcs line: 8 specificity: (0,1,0) order: 0 +``` + +## Context and diff on a real document + +`approvalStatus` is a lifecycle value, not data — so it is a context: + +```console +$ hypercode emit examcalc.hc --hcs examcalc.hcs > draft.ir.json +$ hypercode emit examcalc.hc --hcs examcalc.hcs --ctx stage=approved > approved.ir.json +$ hypercode diff draft.ir.json approved.ir.json +~ Package#examcalc > Metadata + ~ approval_status: draft → approved + was: Metadata @ examcalc.hcs:30 + now: Metadata @ examcalc.hcs:39 + +1 affected node(s) +``` + +One affected node — approving a package invalidates exactly the artifacts +derived from its metadata, nothing else. Contracts gate edits the same way +they gate the other examples: `card_min: -1` or a missing relation `domain` +fails `hypercode validate` before the package ever reaches `ontologyc`. + +## The honest part + +For a *single* package the size is parity, not victory: 201 meaningful spec +lines vs 212 meaningful YAML lines. The compression argument starts at the +**second** package, when `.entity`/`.command` defaults and the contracts move +to a shared baseline imported by every package sheet (`@import`, HC-116) — +the same shape as the [Kustomize comparison](../kustomize-comparison/)'s +tenant sheets. What a single package gains today is not size: it is selector +defaults, per-context lifecycle, semantic diff, contract gating and +provenance on a document that previously had none of those. + +Everything that hurt while writing this is recorded in +[DOCS/Dogfooding.md](../../DOCS/Dogfooding.md) — the friction log is the +deliverable dogfooding exists to produce. + +| File | Role | +|---|---| +| `examcalc.hc` | the package topology (42 meaningful lines) | +| `examcalc.hcs` | kind defaults + per-node specifics + `@stage` + contracts | +| `backend.py` | consumer adapter: IR v2 → DomainOntologyPackage; `--check` for CI | +| `expected/domain-ontology-package.yaml` | verbatim copy of the Ontology repo original | diff --git a/Examples/ontology-backend/backend.py b/Examples/ontology-backend/backend.py new file mode 100644 index 0000000..ea8ad51 --- /dev/null +++ b/Examples/ontology-backend/backend.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""HC-121 dogfooding: the worked example of DOCS/Backends.md, runnable. + +Consumer-side adapter: emit the resolved IR v2 of examcalc.{hc,hcs}, map it +to a DomainOntologyPackage document (the Ontology repo's schema), and — with +--check — compare it *semantically* against the real, hand-written package +vendored in expected/ (a verbatim copy of +Ontology/SPECS/ontology/packages/examcalc/domain-ontology-package.yaml). + +All schema knowledge (envelope constants, key names, list encodings) lives +here, on the consumer side — Hypercode never learns the ontology schema. +""" +import argparse +import json +import os +import subprocess +import sys + +import yaml + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, "..", "..")) + +# Envelope knowledge owned by this backend, not by the spec. +API_VERSION = "ontology.specgraph.io/v1alpha1" +KIND = "DomainOntologyPackage" + + +def emit_ir(ctx): + binary = os.environ.get( + "HYPERCODE_BIN", os.path.join(REPO, ".build", "debug", "hypercode")) + cmd = [binary, "emit", os.path.join(HERE, "examcalc.hc"), + "--hcs", os.path.join(HERE, "examcalc.hcs"), "--format", "json"] + for pair in ctx or []: + cmd += ["--ctx", pair] + out = subprocess.run(cmd, capture_output=True, text=True, cwd=REPO) + if out.returncode != 0: + sys.exit(f"emit failed: {out.stderr.strip()}") + return json.loads(out.stdout) + + +def props(node): + """Resolved properties of a node as {key: typed value}.""" + return {key: entry["value"] for key, entry in node["properties"].items()} + + +def children(node, node_type): + return [c for c in node["children"] if c["type"] == node_type] + + +def csv(value): + """Core has no list values — lists travel as comma-joined strings.""" + return [item.strip() for item in str(value).split(",")] + + +def cardinality(values): + def bound(v): + return v if isinstance(v, int) else str(v) + return {"min": bound(values["card_min"]), "max": bound(values["card_max"])} + + +def build(ir): + package = ir["nodes"][0] + by_type = {c["type"]: c for c in package["children"]} + + meta = props(by_type["Metadata"]) + doc = { + "apiVersion": API_VERSION, + "kind": KIND, + "metadata": { + "id": meta["package_id"], + "namespace": meta["namespace"], + "version": meta["version"], + "publisher": meta["publisher"], + "source": meta["source"], + "approvalStatus": meta["approval_status"], + }, + } + + imports = [] + for imp in children(by_type["Imports"], "Import"): + values = props(imp) + imports.append({"id": values["import_id"], + "namespace": values["namespace"], + "version": values["version"]}) + + classes = {} + for cls in children(by_type["Classes"], "Class"): + values = props(cls) + entry = {"extends": values["extends"]} + if "implements" in values: + entry["implements"] = csv(values["implements"]) + if "lifecycle" in values: + entry["lifecycle"] = values["lifecycle"] + entry["description"] = values["description"] + if values.get("central"): + entry["central"] = True + fields = {} + for field in children(cls, "Field"): + fv = props(field) + fields[field["id"]] = { + "type": fv["type"], "required": fv["required"], + "description": fv["description"], + } + if fields: + entry["fields"] = fields + classes[cls["id"]] = entry + + relations = {} + for rel in children(by_type["Relations"], "Relation"): + values = props(rel) + targets = csv(values["range"]) + entry = { + "domain": values["domain"], + "range": targets[0] if len(targets) == 1 else {"oneOf": targets}, + "cardinality": cardinality(values), + } + if "description" in values: + entry["description"] = values["description"] + relations[rel["id"]] = entry + + policies = {} + for pol in children(by_type["Policies"], "Policy"): + values = props(pol) + policies[pol["id"]] = { + "extends": values["extends"], + "enforceability": values["enforceability"], + "appliesTo": csv(values["applies_to"]), + "text": values["text"], + } + + machines = {} + for machine in children(by_type["StateMachines"], "Machine"): + values = props(machine) + transitions = [] + for tr in children(machine, "Transition"): + tv = props(tr) + entry = {"from": tv["from"], "to": tv["to"]} + if "command" in tv: + entry["command"] = tv["command"] + if "event" in tv: + entry["event"] = tv["event"] + transitions.append(entry) + machines[machine["id"]] = {"states": csv(values["states"]), + "transitions": transitions} + + compat = props(by_type["Compatibility"]) + doc["spec"] = { + "imports": imports, + "classes": classes, + "protocols": {}, + "relations": relations, + "policies": policies, + "stateMachines": machines, + "compatibility": { + "patch": {"allowed": csv(compat["patch_allowed"])}, + "minor": {"allowed": csv(compat["minor_allowed"])}, + "major": {"requires": csv(compat["major_requires"])}, + }, + } + return doc + + +def diff_paths(expected, actual, path="$"): + """Human-readable structural differences (first 20).""" + out = [] + if isinstance(expected, dict) and isinstance(actual, dict): + for key in sorted(set(expected) | set(actual)): + if key not in expected: + out.append(f"{path}.{key}: unexpected") + elif key not in actual: + out.append(f"{path}.{key}: missing") + else: + out += diff_paths(expected[key], actual[key], f"{path}.{key}") + elif isinstance(expected, list) and isinstance(actual, list): + if len(expected) != len(actual): + out.append(f"{path}: length {len(expected)} != {len(actual)}") + for i, (e, a) in enumerate(zip(expected, actual)): + out += diff_paths(e, a, f"{path}[{i}]") + elif expected != actual: + out.append(f"{path}: {expected!r} != {actual!r}") + return out + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ctx", action="append", default=None, + help="key=value resolution context (e.g. stage=approved)") + parser.add_argument("--out", help="write the generated YAML here") + parser.add_argument("--check", action="store_true", + help="compare semantically against expected/ (CI)") + args = parser.parse_args() + + doc = build(emit_ir(args.ctx)) + rendered = yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, + default_flow_style=False, width=100) + + if args.out: + with open(args.out, "w") as handle: + handle.write(rendered) + if args.check: + expected_path = os.path.join(HERE, "expected", + "domain-ontology-package.yaml") + expected = yaml.safe_load(open(expected_path)) + differences = diff_paths(expected, doc) + if differences: + print("generated package differs from the Ontology repo original:", + file=sys.stderr) + for line in differences[:20]: + print(f" {line}", file=sys.stderr) + return 1 + print("generated DomainOntologyPackage is semantically identical " + "to the Ontology repo original (240 lines of YAML)") + return 0 + if not args.out: + print(rendered, end="") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Examples/ontology-backend/examcalc.hc b/Examples/ontology-backend/examcalc.hc new file mode 100644 index 0000000..c66be53 --- /dev/null +++ b/Examples/ontology-backend/examcalc.hc @@ -0,0 +1,42 @@ +Package#examcalc + Metadata + Imports + Import#specgraph-foundation + Classes + Class.entity#Exam + Field#title + Field#durationMinutes + Class.entity#ExamSession + Class.entity#TabletDevice + Class.capability#CalculatorFunction + Class.entity#FunctionSet + Class.entity#ExamPolicyProfile + Class.entity#ExamModeSession + Class.event#PolicyViolation + Class.entity#AuditLogEntry + Class.command#StartExamMode + Class.command#VerifyDeviceAndPolicy + Class.command#LockCalculator + Class.command#EndExamMode + Relations + Relation#requires_policy + Relation#has_session + Relation#allows + Relation#denies + Relation#includes_function + Relation#enforces + Relation#occurred_during + Relation#records + Policies + Policy#DenyByDefaultPolicy + Policy#PolicyMustBeSigned + Policy#PolicyMustBeDeviceVerifiable + Policy#NoNetworkDuringExam + StateMachines + Machine#ExamModeSessionState + Transition#start + Transition#verify + Transition#violation + Transition#lock + Transition#complete + Compatibility diff --git a/Examples/ontology-backend/examcalc.hcs b/Examples/ontology-backend/examcalc.hcs new file mode 100644 index 0000000..0130421 --- /dev/null +++ b/Examples/ontology-backend/examcalc.hcs @@ -0,0 +1,223 @@ +# HC-121 dogfooding: the real `examcalc` DomainOntologyPackage (Ontology repo) +# as a cascade sheet. Selector defaults carry what the YAML repeats per entry; +# id rules carry only what is specific. Lists are comma-joined strings — core +# has no list values (see DOCS/Dogfooding.md, friction F1). + +# ---- defaults by kind: the cascade earning its keep ---- + +.entity: + extends: "sg:DomainEntity" + +.capability: + extends: "sg:Capability" + +.command: + extends: "sg:Command" + +.event: + extends: "sg:Event" + +Relation: + card_min: 0 + card_max: "*" + +Policy: + extends: "sg:Policy" + enforceability: "runtime" + +# ---- package metadata ---- + +Metadata: + package_id: "edu.university.examcalc" + namespace: "examcalc" + version: "0.1.0" + publisher: "OntologyService" + source: "SPECS/raw/Агентная_Операционная_Система_-_Branch_SpecGraph_-_Онтологии.json" + approval_status: "draft" + +@stage[approved]: + Metadata: + approval_status: "approved" + +'#specgraph-foundation': + import_id: "specgraph.foundation" + namespace: "sg" + version: "0.1.0" + +# ---- classes: only the specifics ---- + +'#Exam': + implements: "sg:Auditable" + description: "University exam that requires a controlled calculator policy." + +'#title': + type: "string" + required: true + description: "Human-readable exam title." + +'#durationMinutes': + type: "integer" + required: false + description: "Planned exam duration in minutes." + +'#ExamSession': + implements: "sg:TimeBound" + description: "Time-bounded sitting of an exam." + +'#TabletDevice': + implements: "sg:DeviceBound, sg:Auditable" + description: "Managed tablet used during an exam." + +'#CalculatorFunction': + implements: "sg:RestrictableCapability" + description: "Calculator capability that can be allowed or denied by an exam policy." + +'#FunctionSet': + description: "Named collection of calculator functions." + +'#ExamPolicyProfile': + implements: "sg:Versioned, sg:Approvable, sg:Signable, sg:Auditable, sg:TimeBound" + description: "Signed and approved policy that governs allowed calculator behavior for an exam." + central: true + +'#ExamModeSession': + implements: "sg:Auditable, sg:DeviceBound" + lifecycle: "ExamModeSessionState" + description: "Runtime enforcement session for controlled calculator use." + +'#PolicyViolation': + implements: "sg:Auditable" + description: "Attempted or detected violation of the active exam calculator policy." + +'#AuditLogEntry': + implements: "sg:Auditable" + description: "Durable audit record for policy enforcement and review." + +'#StartExamMode': + description: "Request to start exam mode for a tablet and selected exam policy." + +'#VerifyDeviceAndPolicy': + description: "Request to verify that the active policy is signed and available on the target device." + +'#LockCalculator': + description: "Request to lock calculator access after a detected policy violation." + +'#EndExamMode': + description: "Request to complete an active exam mode session." + +# ---- relations: defaults cover 0..*; only 1..1 pairs override ---- + +'#requires_policy': + domain: "Exam" + range: "ExamPolicyProfile" + card_min: 1 + card_max: 1 + description: "Every exam requires exactly one active policy profile." + +'#has_session': + domain: "Exam" + range: "ExamSession" + +'#allows': + domain: "ExamPolicyProfile" + range: "CalculatorFunction" + +'#denies': + domain: "ExamPolicyProfile" + range: "CalculatorFunction" + +'#includes_function': + domain: "FunctionSet" + range: "CalculatorFunction" + +'#enforces': + domain: "ExamModeSession" + range: "ExamPolicyProfile" + card_min: 1 + card_max: 1 + +'#occurred_during': + domain: "PolicyViolation" + range: "ExamModeSession" + card_min: 1 + card_max: 1 + +'#records': + domain: "AuditLogEntry" + range: "PolicyViolation, ExamModeSession" + +# ---- policies ---- + +'#DenyByDefaultPolicy': + applies_to: "ExamPolicyProfile" + text: "Any calculator function not explicitly allowed by the active policy must be denied." + +'#PolicyMustBeSigned': + applies_to: "ExamPolicyProfile" + text: "Exam policy profile must be signed before activation." + +'#PolicyMustBeDeviceVerifiable': + applies_to: "ExamModeSession" + text: "Exam mode session must verify the active policy on the device before entering active state." + +'#NoNetworkDuringExam': + applies_to: "ExamModeSession" + text: "Calculator app must not access network during exam mode unless explicitly allowed." + +# ---- state machine ---- + +'#ExamModeSessionState': + states: "not_started, pending_device_verification, active, violation_detected, locked, completed, failed" + +'#start': + from: "not_started" + to: "pending_device_verification" + command: "StartExamMode" + +'#verify': + from: "pending_device_verification" + to: "active" + command: "VerifyDeviceAndPolicy" + +'#violation': + from: "active" + to: "violation_detected" + event: "PolicyViolation" + +'#lock': + from: "violation_detected" + to: "locked" + command: "LockCalculator" + +'#complete': + from: "active" + to: "completed" + command: "EndExamMode" + +# ---- compatibility policy ---- + +Compatibility: + patch_allowed: "add description, add alias, add non-breaking metadata" + minor_allowed: "add class, add relation, add optional property, add protocol" + major_requires: "remove class, remove relation, change relation domain, change relation range, make optional field required, change meaning of central concept" + +# ---- invariants ---- + +@contract: + Metadata: + package_id: string + version: string + approval_status: string + Relation: + domain: string + range: string + card_min: int >= 0 + Field: + type: string + required: bool + Policy: + enforceability: string + text: string + Transition: + from: string + to: string diff --git a/Examples/ontology-backend/expected/domain-ontology-package.yaml b/Examples/ontology-backend/expected/domain-ontology-package.yaml new file mode 100644 index 0000000..8ddb270 --- /dev/null +++ b/Examples/ontology-backend/expected/domain-ontology-package.yaml @@ -0,0 +1,240 @@ +apiVersion: ontology.specgraph.io/v1alpha1 +kind: DomainOntologyPackage +metadata: + id: edu.university.examcalc + namespace: examcalc + version: 0.1.0 + publisher: OntologyService + source: SPECS/raw/Агентная_Операционная_Система_-_Branch_SpecGraph_-_Онтологии.json + approvalStatus: draft +spec: + imports: + - id: specgraph.foundation + namespace: sg + version: 0.1.0 + + classes: + Exam: + extends: sg:DomainEntity + implements: + - sg:Auditable + description: University exam that requires a controlled calculator policy. + fields: + title: + type: string + required: true + description: Human-readable exam title. + durationMinutes: + type: integer + required: false + description: Planned exam duration in minutes. + + ExamSession: + extends: sg:DomainEntity + implements: + - sg:TimeBound + description: Time-bounded sitting of an exam. + + TabletDevice: + extends: sg:DomainEntity + implements: + - sg:DeviceBound + - sg:Auditable + description: Managed tablet used during an exam. + + CalculatorFunction: + extends: sg:Capability + implements: + - sg:RestrictableCapability + description: Calculator capability that can be allowed or denied by an exam policy. + + FunctionSet: + extends: sg:DomainEntity + description: Named collection of calculator functions. + + ExamPolicyProfile: + extends: sg:DomainEntity + implements: + - sg:Versioned + - sg:Approvable + - sg:Signable + - sg:Auditable + - sg:TimeBound + description: Signed and approved policy that governs allowed calculator behavior for an exam. + central: true + + ExamModeSession: + extends: sg:DomainEntity + implements: + - sg:Auditable + - sg:DeviceBound + lifecycle: ExamModeSessionState + description: Runtime enforcement session for controlled calculator use. + + PolicyViolation: + extends: sg:Event + implements: + - sg:Auditable + description: Attempted or detected violation of the active exam calculator policy. + + AuditLogEntry: + extends: sg:DomainEntity + implements: + - sg:Auditable + description: Durable audit record for policy enforcement and review. + + StartExamMode: + extends: sg:Command + description: Request to start exam mode for a tablet and selected exam policy. + + VerifyDeviceAndPolicy: + extends: sg:Command + description: Request to verify that the active policy is signed and available on the target device. + + LockCalculator: + extends: sg:Command + description: Request to lock calculator access after a detected policy violation. + + EndExamMode: + extends: sg:Command + description: Request to complete an active exam mode session. + + protocols: {} + + relations: + requires_policy: + domain: Exam + range: ExamPolicyProfile + cardinality: + min: 1 + max: 1 + description: Every exam requires exactly one active policy profile. + + has_session: + domain: Exam + range: ExamSession + cardinality: + min: 0 + max: "*" + + allows: + domain: ExamPolicyProfile + range: CalculatorFunction + cardinality: + min: 0 + max: "*" + + denies: + domain: ExamPolicyProfile + range: CalculatorFunction + cardinality: + min: 0 + max: "*" + + includes_function: + domain: FunctionSet + range: CalculatorFunction + cardinality: + min: 0 + max: "*" + + enforces: + domain: ExamModeSession + range: ExamPolicyProfile + cardinality: + min: 1 + max: 1 + + occurred_during: + domain: PolicyViolation + range: ExamModeSession + cardinality: + min: 1 + max: 1 + + records: + domain: AuditLogEntry + range: + oneOf: + - PolicyViolation + - ExamModeSession + cardinality: + min: 0 + max: "*" + + policies: + DenyByDefaultPolicy: + extends: sg:Policy + enforceability: runtime + appliesTo: + - ExamPolicyProfile + text: Any calculator function not explicitly allowed by the active policy must be denied. + + PolicyMustBeSigned: + extends: sg:Policy + enforceability: runtime + appliesTo: + - ExamPolicyProfile + text: Exam policy profile must be signed before activation. + + PolicyMustBeDeviceVerifiable: + extends: sg:Policy + enforceability: runtime + appliesTo: + - ExamModeSession + text: Exam mode session must verify the active policy on the device before entering active state. + + NoNetworkDuringExam: + extends: sg:Policy + enforceability: runtime + appliesTo: + - ExamModeSession + text: Calculator app must not access network during exam mode unless explicitly allowed. + + stateMachines: + ExamModeSessionState: + states: + - not_started + - pending_device_verification + - active + - violation_detected + - locked + - completed + - failed + transitions: + - from: not_started + to: pending_device_verification + command: StartExamMode + - from: pending_device_verification + to: active + command: VerifyDeviceAndPolicy + - from: active + to: violation_detected + event: PolicyViolation + - from: violation_detected + to: locked + command: LockCalculator + - from: active + to: completed + command: EndExamMode + + compatibility: + patch: + allowed: + - add description + - add alias + - add non-breaking metadata + minor: + allowed: + - add class + - add relation + - add optional property + - add protocol + major: + requires: + - remove class + - remove relation + - change relation domain + - change relation range + - make optional field required + - change meaning of central concept diff --git a/workplan.md b/workplan.md index 93fefb7..adba1ef 100644 --- a/workplan.md +++ b/workplan.md @@ -104,7 +104,7 @@ P1 — built on v2: ## M9 — Validation & adoption (DOCS/Positioning.md) - [x] HC-120 Kustomize comparison demo — `Examples/kustomize-comparison/`: 3 tenants × 3 envs maintained twice (idiomatic Kustomize components vs `.hc` + `@import`ed tenant sheets); computed metrics (28 files/278 lines/86% duplication vs 5/57/30%), "why is this value here" via `explain`, affected-target precision via `hypercode diff`, and the own failure mode (specificity beats source order) diagnosed via `explain`; `metrics.py --check` validates all 9 targets in CI -- ⬜ HC-121 Dogfooding as the primary adoption path — Hyperprompt / Ontology consume the resolved IR (consumer-side dialects & backends per `DOCS/Dialects.md` / `DOCS/Backends.md`) +- 🔜 HC-121 Dogfooding as the primary adoption path — **first artifact shipped**: the real Ontology `examcalc` package round-trips through IR v2 (`Examples/ontology-backend/`: kind defaults via selectors, `@stage` lifecycle context, contracts, consumer adapter; semantic match with the original verified in CI), friction log started in `DOCS/Dogfooding.md` (F1–F5); remaining: `import-hypercode` step inside `ontologyc` itself + the Hyperprompt configuration exercise - 🅿️ HC-122 SLSA-like generation attestation chain — signed `.hc`/`.hcs` → IR hash → generator identity/version → artifact hashes → validator report (RFC §8, §9.8) - 🅿️ HC-123 Agent Passport / 0AL integration — attestation chain plugs into 0AL's signed-agent model - [x] HC-124 End-to-end AI codegen demo — `Examples/codegen-demo/`: service spec → IR v2 → Claude-generated module per node (embedded node hash + provenance comments) → `check.py` validates artifacts against the same contracts (HC2104-gen) and scopes regeneration by node hash; `generate.sh` regenerates stale modules via `claude -p`; checked in CI on every push From d733d5d4aeb9bb989fae9cca69d9666ec28c3f6d Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Fri, 12 Jun 2026 21:13:08 +0300 Subject: [PATCH 2/4] =?UTF-8?q?ci:=20install=20pyyaml=20into=20a=20venv=20?= =?UTF-8?q?=E2=80=94=20macos=20runner=20Python=20is=20PEP=20668-managed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Homebrew-managed system Python on macos-latest rejects 'pip install' into the global environment; a throwaway venv is the self-contained fix (no extra action dependency). Co-Authored-By: Claude Fable 5 --- .github/workflows/swift.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 5c5665e..c5afdbb 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -38,6 +38,9 @@ jobs: run: python3 Examples/kustomize-comparison/metrics.py --check - name: Ontology backend — regenerated package matches the original + # macos-latest system Python is Homebrew-managed (PEP 668) — pip + # refuses to install into it; a throwaway venv sidesteps that. run: | - python3 -m pip install --quiet pyyaml - python3 Examples/ontology-backend/backend.py --check \ No newline at end of file + python3 -m venv /tmp/ontology-venv + /tmp/ontology-venv/bin/pip install --quiet pyyaml + /tmp/ontology-venv/bin/python Examples/ontology-backend/backend.py --check \ No newline at end of file From b83a5def30170aca7fb4259f94af7edda0bf6562 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Fri, 12 Jun 2026 21:21:26 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20#30=20review=20=E2=80=94?= =?UTF-8?q?=20UTF-8=20boundaries,=20strict=20section=20detection=20in=20ba?= =?UTF-8?q?ckend.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subprocess decodes the emitted IR as UTF-8 explicitly (the example's Cyrillic source path must not depend on the process locale); - expected-YAML read and --out write are context-managed UTF-8; - duplicate or missing top-level package sections fail with a clear error instead of a silent dict overwrite and a confusing diff. Co-Authored-By: Claude Fable 5 --- Examples/ontology-backend/backend.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Examples/ontology-backend/backend.py b/Examples/ontology-backend/backend.py index ea8ad51..62c4cec 100644 --- a/Examples/ontology-backend/backend.py +++ b/Examples/ontology-backend/backend.py @@ -33,7 +33,7 @@ def emit_ir(ctx): "--hcs", os.path.join(HERE, "examcalc.hcs"), "--format", "json"] for pair in ctx or []: cmd += ["--ctx", pair] - out = subprocess.run(cmd, capture_output=True, text=True, cwd=REPO) + out = subprocess.run(cmd, capture_output=True, encoding="utf-8", cwd=REPO) if out.returncode != 0: sys.exit(f"emit failed: {out.stderr.strip()}") return json.loads(out.stdout) @@ -59,9 +59,20 @@ def bound(v): return {"min": bound(values["card_min"]), "max": bound(values["card_max"])} +SECTIONS = ["Metadata", "Imports", "Classes", "Relations", "Policies", + "StateMachines", "Compatibility"] + + def build(ir): package = ir["nodes"][0] - by_type = {c["type"]: c for c in package["children"]} + by_type = {} + for child in package["children"]: + if child["type"] in by_type: + sys.exit(f"malformed package: duplicate '{child['type']}' section") + by_type[child["type"]] = child + for section in SECTIONS: + if section not in by_type: + sys.exit(f"malformed package: missing '{section}' section") meta = props(by_type["Metadata"]) doc = { @@ -196,12 +207,13 @@ def main(): default_flow_style=False, width=100) if args.out: - with open(args.out, "w") as handle: + with open(args.out, "w", encoding="utf-8") as handle: handle.write(rendered) if args.check: expected_path = os.path.join(HERE, "expected", "domain-ontology-package.yaml") - expected = yaml.safe_load(open(expected_path)) + with open(expected_path, encoding="utf-8") as handle: + expected = yaml.safe_load(handle) differences = diff_paths(expected, doc) if differences: print("generated package differs from the Ontology repo original:", From f29b8bc0e3c1eab3c500ab30cd2b87956bd54eaf Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 13 Jun 2026 00:32:16 +0300 Subject: [PATCH 4/4] fix: reject duplicate inner ids in the ontology backend Residual-risk follow-up on #30: classes, relations, policies, state machines and fields now insert through a guard that fails with 'malformed package: duplicate ' instead of silently overwriting the earlier entry and surfacing as a confusing semantic diff. (Top-level sections were already strict.) Co-Authored-By: Claude Fable 5 --- Examples/ontology-backend/backend.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/Examples/ontology-backend/backend.py b/Examples/ontology-backend/backend.py index 62c4cec..dff889a 100644 --- a/Examples/ontology-backend/backend.py +++ b/Examples/ontology-backend/backend.py @@ -63,6 +63,13 @@ def bound(v): "StateMachines", "Compatibility"] +def put(collection, key, value, kind): + """dict insert that refuses to silently overwrite a duplicate id.""" + if key in collection: + sys.exit(f"malformed package: duplicate {kind} '{key}'") + collection[key] = value + + def build(ir): package = ir["nodes"][0] by_type = {} @@ -109,13 +116,13 @@ def build(ir): fields = {} for field in children(cls, "Field"): fv = props(field) - fields[field["id"]] = { + put(fields, field["id"], { "type": fv["type"], "required": fv["required"], "description": fv["description"], - } + }, "field") if fields: entry["fields"] = fields - classes[cls["id"]] = entry + put(classes, cls["id"], entry, "class") relations = {} for rel in children(by_type["Relations"], "Relation"): @@ -128,17 +135,17 @@ def build(ir): } if "description" in values: entry["description"] = values["description"] - relations[rel["id"]] = entry + put(relations, rel["id"], entry, "relation") policies = {} for pol in children(by_type["Policies"], "Policy"): values = props(pol) - policies[pol["id"]] = { + put(policies, pol["id"], { "extends": values["extends"], "enforceability": values["enforceability"], "appliesTo": csv(values["applies_to"]), "text": values["text"], - } + }, "policy") machines = {} for machine in children(by_type["StateMachines"], "Machine"): @@ -152,8 +159,9 @@ def build(ir): if "event" in tv: entry["event"] = tv["event"] transitions.append(entry) - machines[machine["id"]] = {"states": csv(values["states"]), - "transitions": transitions} + put(machines, machine["id"], + {"states": csv(values["states"]), "transitions": transitions}, + "state machine") compat = props(by_type["Compatibility"]) doc["spec"] = {