from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path from core.epistemic_state import EpistemicState # Dataclass definitions as required by the contract @dataclass(frozen=True, slots=True) class UnitEntry: surface: str singular: str plural: str symbol: str | None dimension: str is_canonical_for_dimension: bool provenance_ids: list[str] epistemic_state: str = EpistemicState.DECODED.value @dataclass(frozen=True, slots=True) class ContainerEntry: surface: str singular: str plural: str default_size: int | None provenance_ids: list[str] @dataclass(frozen=True, slots=True) class DimensionEntry: name: str canonical_unit: str is_derived: bool formula: str | None provenance_ids: list[str] @dataclass(frozen=True, slots=True) class ConversionEdge: edge_id: str from_unit: str to_unit: str ratio: float offset: float dimension: str provenance_ids: list[str] @dataclass(frozen=True, slots=True) class ConversionGraph: edges: list[ConversionEdge] # Private cache variables _UNITS_MAP: dict[str, UnitEntry] = {} _CONTAINERS_MAP: dict[str, ContainerEntry] = {} _DIMENSIONS_MAP: dict[str, DimensionEntry] = {} _CONVERSIONS_BY_DIM: dict[str, list[ConversionEdge]] = {} _LOADED = False ADR_PROVENANCE = "adr-0127:units_pack:2026-05-23" def _ensure_loaded() -> None: global _LOADED if _LOADED: return data_dir = Path(__file__).parent / "data" / "en_units_v1" lexicon_path = data_dir / "lexicon.jsonl" conversions_path = data_dir / "conversions.jsonl" if not lexicon_path.exists(): raise FileNotFoundError(f"lexicon.jsonl missing at {lexicon_path}") # Load lexicon unit_entries = [] canonical_lemmas = set() with lexicon_path.open("r", encoding="utf-8") as f: for line in f: if not line.strip(): continue entry = json.loads(line) tags = entry.get("morphology_tags", []) surface = entry["surface"].lower() if "dimension" in tags: _DIMENSIONS_MAP[surface] = DimensionEntry( name=entry["surface"], canonical_unit=entry["canonical_unit"], is_derived=entry["is_derived"], formula=entry["formula"], provenance_ids=list(entry.get("provenance_ids", [])) ) elif "unit" in tags: unit_entries.append(entry) if entry.get("is_canonical_for_dimension"): canonical_lemmas.add(entry["lemma"].lower()) elif "container" in tags: _CONTAINERS_MAP[surface] = ContainerEntry( surface=entry["surface"], singular=entry["singular"], plural=entry["plural"], default_size=entry.get("default_size"), provenance_ids=list(entry.get("provenance_ids", [])) ) for entry in unit_entries: surface = entry["surface"].lower() lemma = entry["lemma"].lower() is_canon = (lemma in canonical_lemmas) ue = UnitEntry( surface=entry["surface"], singular=entry["singular"], plural=entry["plural"], symbol=entry.get("symbol"), dimension=entry["dimension"], is_canonical_for_dimension=is_canon, provenance_ids=list(entry.get("provenance_ids", [])), epistemic_state=EpistemicState.DECODED.value, ) _UNITS_MAP[surface] = ue _UNITS_MAP[lemma] = ue # Load conversions if conversions_path.exists(): with conversions_path.open("r", encoding="utf-8") as f: for line in f: if not line.strip(): continue edge = json.loads(line) c_edge = ConversionEdge( edge_id=edge["edge_id"], from_unit=edge["from"], to_unit=edge["to"], ratio=float(edge["ratio"]), offset=float(edge.get("offset", 0.0)), dimension=edge["dimension"], provenance_ids=list(edge.get("provenance_ids", [])) ) _CONVERSIONS_BY_DIM.setdefault(c_edge.dimension.lower(), []).append(c_edge) _LOADED = True def _inferred_unit_entry( *, surface: str, singular: str, plural: str, symbol: str | None, dimension: str, is_canonical_for_dimension: bool, provenance_ids: list[str], ) -> UnitEntry: return UnitEntry( surface=surface, singular=singular, plural=plural, symbol=symbol, dimension=dimension, is_canonical_for_dimension=is_canonical_for_dimension, provenance_ids=provenance_ids, epistemic_state=EpistemicState.INFERRED.value, ) # Public API functions def lookup_unit(token: str) -> UnitEntry | None: """Look up unit by singular, plural, symbol surface, or dynamic composition. Curated lexicon hits are tagged ``DECODED``. Dynamically composed units (``per``, ``square``, ``cubic``) are tagged ``INFERRED``: derived from decoded primitives by ratified deterministic rules, but not themselves curated lexical entries. """ _ensure_loaded() token_clean = token.strip().lower() # 1. Direct Lookup if token_clean in _UNITS_MAP: return _UNITS_MAP[token_clean] # 2. Composition Rules # Rule A: per if " per " in token_clean: parts = token_clean.split(" per ") if len(parts) == 2: left_str, right_str = parts[0], parts[1] left_unit = lookup_unit(left_str) right_unit = lookup_unit(right_str) if left_unit: # Wage / Unit Price: money per time / count if left_unit.dimension == "money": if right_unit and right_unit.dimension == "time": singular = f"{left_unit.singular} per {right_unit.singular}" plural = f"{left_unit.plural} per {right_unit.singular}" is_canon = (left_unit.singular == "dollar" and right_unit.singular == "hour") return _inferred_unit_entry( surface=token, singular=singular, plural=plural, symbol=f"{left_unit.symbol or '$'}/{right_unit.symbol or 'hr'}", dimension="wage", is_canonical_for_dimension=is_canon, provenance_ids=left_unit.provenance_ids + (right_unit.provenance_ids if right_unit else [ADR_PROVENANCE]) ) else: r_sing = right_unit.singular if right_unit else right_str singular = f"{left_unit.singular} per {r_sing}" plural = f"{left_unit.plural} per {r_sing}" is_canon = (left_unit.singular == "dollar" and r_sing == "item") return _inferred_unit_entry( surface=token, singular=singular, plural=plural, symbol=f"{left_unit.symbol or '$'}/{r_sing}", dimension="unit_price", is_canonical_for_dimension=is_canon, provenance_ids=left_unit.provenance_ids + (right_unit.provenance_ids if right_unit else [ADR_PROVENANCE]) ) # Speed: length per time elif left_unit.dimension == "length" and right_unit and right_unit.dimension == "time": singular = f"{left_unit.singular} per {right_unit.singular}" plural = f"{left_unit.plural} per {right_unit.singular}" is_canon = (left_unit.singular == "mile" and right_unit.singular == "hour") return _inferred_unit_entry( surface=token, singular=singular, plural=plural, symbol="mph" if is_canon else f"{left_unit.symbol or 'ft'}/{right_unit.symbol or 's'}", dimension="speed", is_canonical_for_dimension=is_canon, provenance_ids=left_unit.provenance_ids + right_unit.provenance_ids ) # Density: mass per volume elif left_unit.dimension == "mass" and right_unit and right_unit.dimension == "volume": singular = f"{left_unit.singular} per {right_unit.singular}" plural = f"{left_unit.plural} per {right_unit.singular}" is_canon = (left_unit.singular == "pound" and right_unit.surface == "cubic foot") return _inferred_unit_entry( surface=token, singular=singular, plural=plural, symbol=f"{left_unit.symbol or 'lb'}/{right_unit.symbol or 'cu ft'}", dimension="density", is_canonical_for_dimension=is_canon, provenance_ids=left_unit.provenance_ids + right_unit.provenance_ids ) # Rule B: square if token_clean.startswith("square "): sub_str = token_clean[7:] sub_unit = lookup_unit(sub_str) if sub_unit and sub_unit.dimension == "length": singular = f"square {sub_unit.singular}" plural = f"square {sub_unit.plural}" is_canon = (sub_unit.singular == "foot") return _inferred_unit_entry( surface=token, singular=singular, plural=plural, symbol=f"sq {sub_unit.symbol or 'ft'}", dimension="area", is_canonical_for_dimension=is_canon, provenance_ids=sub_unit.provenance_ids ) # Rule C: cubic if token_clean.startswith("cubic "): sub_str = token_clean[6:] sub_unit = lookup_unit(sub_str) if sub_unit and sub_unit.dimension == "length": singular = f"cubic {sub_unit.singular}" plural = f"cubic {sub_unit.plural}" return _inferred_unit_entry( surface=token, singular=singular, plural=plural, symbol=f"cu {sub_unit.symbol or 'ft'}", dimension="volume", is_canonical_for_dimension=False, provenance_ids=sub_unit.provenance_ids ) return None def lookup_container(token: str) -> ContainerEntry | None: """Look up container by singular or plural surface.""" _ensure_loaded() return _CONTAINERS_MAP.get(token.strip().lower()) def lookup_dimension(name: str) -> DimensionEntry | None: """Look up dimension by name.""" _ensure_loaded() return _DIMENSIONS_MAP.get(name.strip().lower()) def get_conversion_graph(dimension: str) -> ConversionGraph: """Get the conversions subgraph for a given dimension.""" _ensure_loaded() edges = _CONVERSIONS_BY_DIM.get(dimension.strip().lower(), []) return ConversionGraph(edges=list(edges)) def canonical_unit_for(dimension: str) -> str: """Get the canonical unit name for a given dimension.""" _ensure_loaded() dim = _DIMENSIONS_MAP.get(dimension.strip().lower()) if not dim: raise ValueError(f"Unknown dimension: {dimension}") # Return the singular surface form of the canonical unit if registered, # otherwise fallback to dim.canonical_unit. unit = lookup_unit(dim.canonical_unit) if unit: return unit.singular return dim.canonical_unit # --------------------------------------------------------------------------- # ADR-0128 numerics-pack re-exports (deferred coordination from ADR-0128 brief) # # en_numerics_v1's loader functions live in language_packs/numerics_loader.py # (per the brief's concurrency clause that allowed parallel development). # Re-exporting them here gives callers a single import path # (`from language_packs.loader import lookup_cardinal`) while keeping the # numerics implementation in its own domain-cohesive module. # --------------------------------------------------------------------------- from language_packs.numerics_loader import ( # noqa: E402 CardinalEntry, ComparisonAnchorEntry, FractionEntry, MultiplierEntry, NumberFormatEntry, OrdinalEntry, ParsedNumber, QuantifierEntry, lookup_cardinal, lookup_comparison_anchor, lookup_comparison_anchors, lookup_fraction, lookup_multiplier, lookup_ordinal, lookup_quantifier, match_number_format, number_format_entries, parse_compound_cardinal, ) __all__ = [ # ADR-0127 units pack "UnitEntry", "ContainerEntry", "DimensionEntry", "ConversionEdge", "ConversionGraph", "lookup_unit", "lookup_container", "lookup_dimension", "get_conversion_graph", "canonical_unit_for", # ADR-0128 numerics pack (re-exported from numerics_loader) "CardinalEntry", "OrdinalEntry", "FractionEntry", "MultiplierEntry", "QuantifierEntry", "ComparisonAnchorEntry", "NumberFormatEntry", "ParsedNumber", "lookup_cardinal", "lookup_ordinal", "lookup_fraction", "lookup_quantifier", "lookup_multiplier", "lookup_comparison_anchor", "lookup_comparison_anchors", "number_format_entries", "match_number_format", "parse_compound_cardinal", ]