core/formation/templates/composed_relation.py
Shay 7feb239fdd feat(formation/templates): four new course templates + shared helpers
Adds the four templates called out in docs/teaching_order.md so the formation
pipeline can ratify more than just definitional ontologies:

* composed_relation — Layer 4.  Chains are the unit of mastery; each chain of
  length >= 2 emits a composed_relations entry with composition_kind
  (transitive | lifting), an inferred relation, and chain-break adversarial
  probes drawn from counters or canned.
* procedural — ordered state transitions; strict_linear_topo refuses
  branches, cycles, and disconnected components at render time.
  ordering_hints validated against the linear chain.  Canned violation
  probes for precondition_violation / step_skip / back_edge.
* falsification — counter-example-driven.  Counters move to Phase 2 paired
  with coherent alternatives drawn from relations sharing the same head.
  Unmatched counters surface in unmatched_counters; false-coherent probes
  emitted per pair.
* identity_anchor — Layer 1 seeding.  Concepts interpreted as identity axes
  ranked by ordering_hints; counters interpreted as override attempts;
  canned IDENTITY_OVERRIDE_PROBES always appended.

Common helpers extracted to formation/templates/_common.py: canonical
constants (MAX_VERSOR_CONDITION, RATIFICATION_GATES, PROMOTION_PATH,
IDENTITY_OVERRIDE_PROBES, NORMALIZATION_FORBIDDEN_SITES), deterministic
ordering (sorted_concepts/_counters/_hints, topo_sorted_relations,
strict_linear_topo), payload builders, geometric_dependencies,
maximal_chain_walks, adversarial_block, course_id, subject_payload,
substrate_invariants_payload, phase_5_payload.

formation/templates/__init__.py now dispatches via a lazy-import _REGISTRY
keyed by template_id; registered_template_ids() exposed for callers and
tests.  definition.py refactored to use _common verbatim — byte-stability
preserved (existing test_compose.py still passes; test_sha_stable_across_
subprocess unchanged).

Tests: 44 new tests across test_template_{composed_relation,procedural,
falsification,identity_anchor,registry}.py.  Each new template gets
determinism, paradigm-structure, error-handling, and cross-subprocess SHA
stability tests; registry test asserts the five known ids and that
identical inputs through different templates produce different SHAs.

Formation suite: 138 -> 182 passing.  cognition (121) and smoke (67)
suites unchanged.  ratify.py enforcement of the new paradigm-specific
gates (every_composed_relation_replayed, linear_order_strict, etc.)
remains a documented follow-up — templates declare the gates in their
phase_5 body so the ratifier extension is purely additive.
2026-05-17 18:59:15 -07:00

236 lines
7.9 KiB
Python

"""The ``composed_relation`` template — chains as the unit of mastery.
Layer 4 of the teaching order (see ``docs/teaching_order.md``). Where the
``definition`` template treats every relation as an independent edge, this
template treats *chains of relations* as first-class objects. Each maximal
chain of length >= 2 produces a ``composed_relation`` entry that names the
inferred relation the chain licenses, classifies the composition kind, and
ties back to the constituent edges by triple.
Composition kinds:
* ``transitive`` — every edge in the chain shares the same ``relation``
predicate (``A R B; B R C => A R C``).
* ``lifting`` — predicates differ but compose into a new asserted relation.
Phase 4 augments adversarial probes with one *chain-break* counter per
composed_relation (drawn from ``counters`` whose ``head`` matches the
chain's head and whose ``tail`` matches the chain's tail). If no
chain-break counter is provided, a canned probe is emitted so the
adversarial slot is never empty for a chain.
Paradigm-specific gates:
every_composed_relation_replayed
"""
from __future__ import annotations
from dataclasses import dataclass
from formation.candidate import CounterCandidate, RelationCandidate
from formation.course import SubjectSpec, ValidatedTripleSet
from formation.templates._common import (
adversarial_block,
concept_payload,
course_id,
geometric_dependencies,
phase_5_payload,
relation_payload,
sorted_concepts,
sorted_counters,
source_payload,
sorted_sources,
subject_payload,
substrate_invariants_payload,
topo_sorted_relations,
)
TEMPLATE_ID: str = "composed_relation"
TEMPLATE_VERSION: str = "1.0.0"
@dataclass(frozen=True, slots=True)
class ComposedRelationTemplate:
template_id: str = TEMPLATE_ID
template_version: str = TEMPLATE_VERSION
def render(
self,
validated_set: ValidatedTripleSet,
spec: SubjectSpec,
source_bundle_sha: str,
) -> dict[str, object]:
if len(validated_set.relations) < 2:
raise ValueError(
"composed_relation: at least two relations required to form a chain"
)
concepts = sorted_concepts(validated_set.concepts)
relations = topo_sorted_relations(validated_set.relations)
counters = sorted_counters(validated_set.counters)
chains = _build_chains(relations)
composed = [_composed_relation_payload(chain) for chain in chains]
walks = _composed_walks(chains)
chain_break_probes = _chain_break_probes(chains, counters)
body: dict[str, object] = {
"course_id": course_id(spec, self.template_id, self.template_version),
"paradigm": "chained_relation_composition",
"template_id": self.template_id,
"template_version": self.template_version,
"source_bundle_sha": source_bundle_sha,
"subject": subject_payload(spec),
"geometric_dependencies": geometric_dependencies(relations),
"substrate_invariants": substrate_invariants_payload(),
"phase_1_ontological_seeding": {
"concepts": [concept_payload(c) for c in concepts],
},
"phase_2_axiomatic_rotor_scaffolding": {
"relations": [relation_payload(r) for r in relations],
},
"phase_3_holonomic_syllabus_walk": {
"walks": walks,
"composed_relations": composed,
},
"phase_4_epistemic_boundary_hardening": {
"adversarial_corrections": adversarial_block(counters),
"chain_break_probes": chain_break_probes,
},
"phase_5_ratified_consolidation": phase_5_payload(
extra_gates=("every_composed_relation_replayed",)
),
}
return body
# ---------- chain building ----------
def _build_chains(
relations: list[RelationCandidate],
) -> list[list[RelationCandidate]]:
"""Greedily extract maximal chains. Only chains of length >= 2 are returned.
Length-1 stragglers still appear in Phase 2's full relation list (so they
are not silently dropped), but they do not produce composed_relation
entries since they form no composition.
"""
used: set[int] = set()
chains: list[list[RelationCandidate]] = []
while len(used) < len(relations):
seed: int | None = None
for i in range(len(relations)):
if i not in used:
seed = i
break
if seed is None:
break
chain: list[RelationCandidate] = [relations[seed]]
used.add(seed)
while True:
tail = chain[-1].tail
extended = False
for j, r in enumerate(relations):
if j in used:
continue
if r.head == tail:
used.add(j)
chain.append(r)
extended = True
break
if not extended:
break
if len(chain) >= 2:
chains.append(chain)
return chains
def _composition_kind(chain: list[RelationCandidate]) -> str:
preds = {r.relation for r in chain}
return "transitive" if len(preds) == 1 else "lifting"
def _composed_relation_payload(
chain: list[RelationCandidate],
) -> dict[str, object]:
kind = _composition_kind(chain)
inferred_relation = chain[0].relation if kind == "transitive" else "composes_to"
return {
"chain_id": f"chain_{chain[0].head}_to_{chain[-1].tail}",
"head": chain[0].head,
"tail": chain[-1].tail,
"inferred_relation": inferred_relation,
"composition_kind": kind,
"verified": "by_walk_only",
"constituent_edges": [
{"head": r.head, "relation": r.relation, "tail": r.tail}
for r in chain
],
}
def _composed_walks(
chains: list[list[RelationCandidate]],
) -> list[dict[str, object]]:
walks: list[dict[str, object]] = []
for i, chain in enumerate(chains):
walks.append(
{
"walk_id": f"walk_{i:04d}",
"steps": [
{"head": r.head, "relation": r.relation, "tail": r.tail}
for r in chain
],
}
)
return walks
def _chain_break_probes(
chains: list[list[RelationCandidate]],
counters: list[CounterCandidate],
) -> list[dict[str, object]]:
"""One probe per chain. Prefer a matching counter; else emit a canned probe.
A "matching counter" is one whose head equals the chain head and whose
tail equals the chain tail — i.e. directly contradicts the inferred
relation the chain produces.
"""
probes: list[dict[str, object]] = []
for i, chain in enumerate(chains):
head = chain[0].head
tail = chain[-1].tail
matched: CounterCandidate | None = None
for c in counters:
if c.head == head and c.tail == tail:
matched = c
break
if matched is not None:
probes.append(
{
"probe_id": f"chain_break_{i:04d}",
"head": head,
"tail": tail,
"counter_relation": matched.relation,
"expected_outcome": "rejected",
"sources": [
source_payload(s) for s in sorted_sources(matched.sources)
],
}
)
else:
probes.append(
{
"probe_id": f"chain_break_{i:04d}",
"head": head,
"tail": tail,
"counter_relation": "spurious_inference",
"expected_outcome": "rejected",
"sources": [],
}
)
return probes
__all__ = ["ComposedRelationTemplate", "TEMPLATE_ID", "TEMPLATE_VERSION"]