Source code for denia.schema

"""Campaign schema: a machine-actionable description of an experimental table.

The schema is the contract between the agentic ingestion layer and the
classical active-learning core. Whether it is produced by an LLM agent
(:class:`denia.agents.SchemaAgent`) or by heuristic inference
(:func:`denia.ingest.infer_schema`), downstream components only ever see
this structure.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional


[docs] class ColumnRole(str, Enum): """Role of a column in an experimental campaign.""" CATEGORY_INPUT = "category_input" # e.g. element identity, preparation method FRACTION_INPUT = "fraction_input" # e.g. mol% paired with a category column NUMERIC_INPUT = "numeric_input" # e.g. temperature, pressure, contact time TARGET = "target" # objective(s) to optimize AUXILIARY = "auxiliary" # measured but not optimized (e.g. conversions) METADATA = "metadata" # ids, references, free text IGNORE = "ignore"
[docs] @dataclass class ColumnSpec: """Specification of a single column.""" name: str role: ColumnRole unit: Optional[str] = None partner: Optional[str] = None # for FRACTION_INPUT: the category column it quantifies maximize: bool = True # for TARGET columns notes: str = ""
[docs] @dataclass class CampaignSchema: """Full schema of an experimental campaign table.""" columns: List[ColumnSpec] = field(default_factory=list) provenance: str = "heuristic" # "heuristic" | "agent" | "user" # ------------------------------------------------------------------ helpers def _by_role(self, *roles: ColumnRole) -> List[str]: return [c.name for c in self.columns if c.role in roles] @property def targets(self) -> List[str]: return self._by_role(ColumnRole.TARGET) @property def numeric_inputs(self) -> List[str]: return self._by_role(ColumnRole.NUMERIC_INPUT) @property def category_inputs(self) -> List[str]: return self._by_role(ColumnRole.CATEGORY_INPUT) @property def fraction_inputs(self) -> List[str]: return self._by_role(ColumnRole.FRACTION_INPUT) @property def inputs(self) -> List[str]: return self._by_role( ColumnRole.CATEGORY_INPUT, ColumnRole.FRACTION_INPUT, ColumnRole.NUMERIC_INPUT ) def spec(self, name: str) -> ColumnSpec: for c in self.columns: if c.name == name: return c raise KeyError(name)
[docs] def composition_pairs(self) -> List[tuple]: """(category_column, fraction_column) pairs, e.g. ('Cation 1', 'Cation 1 mol%').""" pairs = [] for c in self.columns: if c.role is ColumnRole.FRACTION_INPUT and c.partner: pairs.append((c.partner, c.name)) return pairs
# ------------------------------------------------------------------ io def to_dict(self) -> Dict: return { "provenance": self.provenance, "columns": [ { "name": c.name, "role": c.role.value, "unit": c.unit, "partner": c.partner, "maximize": c.maximize, "notes": c.notes, } for c in self.columns ], } @classmethod def from_dict(cls, d: Dict) -> "CampaignSchema": cols = [ ColumnSpec( name=c["name"], role=ColumnRole(c["role"]), unit=c.get("unit"), partner=c.get("partner"), maximize=c.get("maximize", True), notes=c.get("notes", ""), ) for c in d.get("columns", []) ] return cls(columns=cols, provenance=d.get("provenance", "user")) def summary(self) -> str: lines = [f"CampaignSchema (provenance: {self.provenance})"] for role in ColumnRole: names = self._by_role(role) if names: lines.append(f" {role.value:>16}: {', '.join(names)}") return "\n".join(lines)