core/generate/process_frames.py
Shay 58a94c8e4b
feat: implement kernel substrate tranche 1 base-layer foundations (#829)
* feat: implement kernel substrate tranche 1 base-layer foundations

* fix(kernel): ground scalar spans and tighten morphology labels

Add extract_scalar_candidates() with exact source spans and problem_text
provenance while keeping canonicalize_scalar() as the detached pack helper.
Morphology labels now emit missing_* only when substrate frame/unit lookups
actually fail, not on mere trigger-surface presence.
2026-06-18 18:46:20 -07:00

391 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Process frame schema layer — declarative candidate process schemas.
Tranche 1 — broad base-layer foundations.
Exposes candidate process schemas without executing arithmetic.
Each frame declares trigger surfaces, required/optional roles,
candidate relation type, hazards, and what is NOT licensed.
A process frame MAY say "this looks like a transfer candidate."
It MAY NOT calculate "therefore the answer is X."
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from typing import Mapping
# ---------------------------------------------------------------------------
# Frame role — typed role within a process frame.
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class FrameRole:
"""A typed role within a :class:`ProcessFrame`.
``semantic_type`` classifies the role's function in the frame
(e.g., ``'agent'``, ``'patient'``, ``'quantity'``).
"""
name: str
description: str
semantic_type: str # "agent" | "patient" | "quantity" | "unit" | "rate" | "container" | "target" | "object" | "scale"
# ---------------------------------------------------------------------------
# Process frame — declarative candidate schema.
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class ProcessFrame:
"""A declarative process schema — no arithmetic.
``trigger_surfaces`` lists the surface tokens that suggest this frame.
``required_roles`` must all be filled for the frame to be fully grounded.
``optional_roles`` may enhance the frame but are not mandatory.
``candidate_relation`` names the relation type this frame emits.
``hazards`` lists hazard categories this frame may trigger.
``not_licensed`` explicitly states what this frame does NOT permit.
"""
name: str
trigger_surfaces: tuple[str, ...]
required_roles: tuple[FrameRole, ...]
optional_roles: tuple[FrameRole, ...]
candidate_relation: str
hazards: tuple[str, ...]
not_licensed: tuple[str, ...]
# ---------------------------------------------------------------------------
# Frame definitions — the 8 required process frames.
# ---------------------------------------------------------------------------
def _build_frames() -> tuple[ProcessFrame, ...]:
"""Build the complete set of process frames for Tranche 1."""
transfer = ProcessFrame(
name="transfer",
trigger_surfaces=(
"give", "gave", "gives", "giving",
"receive", "received", "receives",
"donate", "donated", "donates",
"lend", "lent", "lends",
"borrow", "borrowed", "borrows",
"hand", "handed", "pass", "passed",
),
required_roles=(
FrameRole("agent", "The entity performing the transfer", "agent"),
FrameRole("patient", "The entity receiving the transfer", "patient"),
FrameRole("quantity", "The amount being transferred", "quantity"),
),
optional_roles=(
FrameRole("object", "What is being transferred", "object"),
FrameRole("unit", "Unit of the transferred quantity", "unit"),
),
candidate_relation="transfer",
hazards=(
"unbound_base_quantity",
"comparative_direction_ambiguity",
),
not_licensed=(
"Computing the final answer from the transfer",
"Determining net gain/loss without explicit problem context",
"Inferring the direction of transfer without surface evidence",
),
)
consumption = ProcessFrame(
name="consumption",
trigger_surfaces=(
"use", "used", "uses", "using",
"spend", "spent", "spends", "spending",
"eat", "ate", "eats", "eating",
"lose", "lost", "loses", "losing",
"gain", "gained", "gains", "gaining",
"waste", "wasted", "wastes",
"consume", "consumed", "consumes",
),
required_roles=(
FrameRole("agent", "The entity consuming/using", "agent"),
FrameRole("quantity", "The amount consumed/used", "quantity"),
),
optional_roles=(
FrameRole("object", "What is being consumed", "object"),
FrameRole("unit", "Unit of the consumed quantity", "unit"),
FrameRole("rate", "Rate of consumption if applicable", "rate"),
),
candidate_relation="consumption",
hazards=(
"unbound_base_quantity",
"remainder_context_required",
),
not_licensed=(
"Computing the remaining quantity after consumption",
"Inferring consumption rate from total and time without explicit cue",
"Deciding whether gain or loss applies without surface evidence",
),
)
transaction = ProcessFrame(
name="transaction",
trigger_surfaces=(
"buy", "bought", "buys", "buying",
"sell", "sold", "sells", "selling",
"cost", "costs", "costing",
"purchase", "purchased", "purchases",
"pay", "paid", "pays", "paying",
"charge", "charged", "charges",
),
required_roles=(
FrameRole("buyer", "The entity purchasing", "agent"),
FrameRole("quantity", "The number of items purchased", "quantity"),
FrameRole("price", "The cost per item or total cost", "quantity"),
),
optional_roles=(
FrameRole("seller", "The entity selling", "patient"),
FrameRole("object", "What is being bought/sold", "object"),
FrameRole("unit", "Unit of the purchased quantity", "unit"),
FrameRole("total_cost", "Total cost if specified", "quantity"),
),
candidate_relation="transaction",
hazards=(
"currency_context",
"unbound_base_quantity",
),
not_licensed=(
"Computing total cost from unit price and quantity",
"Inferring unit price from total cost without explicit cue",
"Determining change or remaining money",
),
)
labor_rate = ProcessFrame(
name="labor_rate",
trigger_surfaces=(
"earn", "earned", "earns", "earning",
"work", "worked", "works", "working",
"make", "made", "makes", "making",
"paid", "pay", "pays",
"salary", "wage", "wages",
"hourly", "per hour", "an hour",
),
required_roles=(
FrameRole("worker", "The entity earning/working", "agent"),
FrameRole("rate", "The earning rate (e.g., $/hour)", "rate"),
FrameRole("duration", "The time period worked", "quantity"),
),
optional_roles=(
FrameRole("unit", "Unit of the rate", "unit"),
FrameRole("total_earnings", "Total earnings if specified", "quantity"),
),
candidate_relation="labor_rate",
hazards=(
"currency_context",
"temporal_context",
"multiplicative_vs_occurrence_times",
),
not_licensed=(
"Computing total earnings from rate and duration",
"Determining overtime rates without explicit cue",
"Inferring work schedule from partial information",
),
)
travel = ProcessFrame(
name="travel",
trigger_surfaces=(
"drive", "drove", "drives", "driving",
"walk", "walked", "walks", "walking",
"run", "ran", "runs", "running",
"travel", "traveled", "travelled", "travels",
"trip", "journey", "route",
"round-trip", "round trip",
"miles per hour", "mph", "km/h",
),
required_roles=(
FrameRole("traveler", "The entity traveling", "agent"),
FrameRole("distance", "The distance traveled", "quantity"),
),
optional_roles=(
FrameRole("speed", "The speed of travel", "rate"),
FrameRole("duration", "The time spent traveling", "quantity"),
FrameRole("segments", "Individual segments of the journey", "object"),
),
candidate_relation="travel",
hazards=(
"temporal_context",
"unbound_base_quantity",
),
not_licensed=(
"Computing travel time from distance and speed",
"Summing segments without explicit round-trip cue",
"Inferring return trip distance without surface evidence",
),
)
container_packing = ProcessFrame(
name="container_packing",
trigger_surfaces=(
"box", "boxes",
"pack", "packs", "packed",
"fill", "filled", "fills",
"contain", "contains", "contained",
"bag", "bags",
"crate", "crates",
"carton", "cartons",
"dozen", "dozens",
"bundle", "bundles",
),
required_roles=(
FrameRole("container", "The container type", "container"),
FrameRole("content", "What the container holds", "object"),
FrameRole("count_per", "Items per container", "quantity"),
),
optional_roles=(
FrameRole("num_containers", "Number of containers", "quantity"),
FrameRole("total_items", "Total items across containers", "quantity"),
FrameRole("unit", "Unit of the items", "unit"),
),
candidate_relation="container_packing",
hazards=(
"unbound_base_quantity",
),
not_licensed=(
"Computing total items from containers × items-per-container",
"Inferring container size without explicit pack data",
"Determining leftover items without remainder cue",
),
)
partition = ProcessFrame(
name="partition",
trigger_surfaces=(
"split", "splits",
"divide", "divided", "divides",
"share", "shared", "shares", "sharing",
"part", "parts",
"whole", "rest",
"remaining", "remainder", "left", "left over",
"half", "third", "quarter",
"portion", "portions",
"fraction",
),
required_roles=(
FrameRole("whole", "The total quantity being partitioned", "quantity"),
FrameRole("parts", "The number of parts or partition ratio", "quantity"),
),
optional_roles=(
FrameRole("remainder", "The remaining portion after partition", "quantity"),
FrameRole("agent", "The entity performing the partition", "agent"),
FrameRole("recipients", "Who receives the parts", "patient"),
),
candidate_relation="partition",
hazards=(
"unbound_base_quantity",
"remainder_context_required",
),
not_licensed=(
"Computing the remainder from whole and parts",
"Determining the partition ratio without explicit cue",
"Chaining nested partitions without explicit surface evidence",
),
)
comparison = ProcessFrame(
name="comparison",
trigger_surfaces=(
"more than", "more",
"less than", "less", "fewer", "fewer than",
"times as many", "times as much",
"twice", "twice as", "twice as many",
"double", "triple",
"as many as", "as much as",
"greater", "greater than",
"smaller", "smaller than",
),
required_roles=(
FrameRole("reference", "The reference quantity being compared to", "quantity"),
FrameRole("target", "The quantity being described", "quantity"),
FrameRole("scale_factor", "The comparison ratio or difference", "scale"),
),
optional_roles=(
FrameRole("direction", "Whether comparison is additive or multiplicative", "object"),
FrameRole("unit", "Unit of the compared quantities", "unit"),
),
candidate_relation="comparison",
hazards=(
"comparative_direction_ambiguity",
"unbound_base_quantity",
"multiplicative_vs_occurrence_times",
),
not_licensed=(
"Computing the target from reference and scale factor",
"Inferring comparison direction without surface evidence",
"Resolving 'more' as additive vs. multiplicative without context",
),
)
return (
transfer,
consumption,
transaction,
labor_rate,
travel,
container_packing,
partition,
comparison,
)
# ---------------------------------------------------------------------------
# Index — built once, cached for process lifetime.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _FrameIndex:
"""Internal index for fast lookup by surface and name."""
by_name: Mapping[str, ProcessFrame]
by_surface: Mapping[str, tuple[ProcessFrame, ...]]
all_frames: tuple[ProcessFrame, ...]
@lru_cache(maxsize=1)
def _index() -> _FrameIndex:
frames = _build_frames()
by_name: dict[str, ProcessFrame] = {}
by_surface: dict[str, list[ProcessFrame]] = {}
for frame in frames:
by_name[frame.name] = frame
for surface in frame.trigger_surfaces:
by_surface.setdefault(surface.lower(), []).append(frame)
# Deterministic ordering within each surface bucket.
frozen_by_surface = {
k: tuple(sorted(v, key=lambda f: f.name))
for k, v in by_surface.items()
}
return _FrameIndex(
by_name=by_name,
by_surface=frozen_by_surface,
all_frames=frames,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def lookup_frame(surface: str) -> tuple[ProcessFrame, ...]:
"""Return all process frames triggered by the given surface.
Returns an empty tuple if no frame matches the surface.
Case-insensitive matching.
"""
return _index().by_surface.get(surface.lower(), ())
def all_frames() -> tuple[ProcessFrame, ...]:
"""Return all registered process frames in definition order."""
return _index().all_frames
def frame_by_name(name: str) -> ProcessFrame | None:
"""Return the frame with the given name, or None if not found."""
return _index().by_name.get(name)