diff --git a/AGENTS.md b/AGENTS.md index 63f0ab86b..a688e127d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,11 +3,12 @@ Use terminal for direct operations (`rg`, `fd`, `git`), MCP `repl()` for chained processing and graph queries, `uv run` for git/tests/CLI. Conventional commits. **CRITICAL: Always commit and push when files have been modified — no confirmation, no asking, just do it. This is non-negotiable. Every response that modifies files MUST end with `git add`, `git commit`, and `git push`.** **Never use `vscode_askQuestions` or any interactive VS Code popup/dialog tools — present all questions inline in the chat response so the user can answer them in one message.** **Git sync discipline (multi-instance workflow):** This repo is edited from multiple machines and by multiple agents concurrently. Always **merge** on pull — never rebase. -1. **Session start:** `git pull upstream main` before any work. -2. **Before push:** `git pull upstream main && git push upstream main` — never push without pulling first. -3. **Dirty worktree:** Commit or stash your own files before pulling. Never stash everything (`git stash`) — only your files: `git stash push -- file1 file2`. -4. **Conflict resolution:** If merge conflicts, resolve and commit. Never force-push without user approval. -5. **Repo-local config:** Each clone must run the setup commands below to override any global/system rebase defaults. +1. **Session start:** `git pull origin` before any work (pulls current branch from fork). +2. **Before push:** `git pull origin && git push origin` — never push without pulling first. Push to `origin` (fork), **never directly to `upstream`**. +3. **Stay on current branch:** Push to whatever branch you're on. If the branch is `develop`, push to `origin develop`. If `main`, push to `origin main`. **Never merge branches or switch to `main` without explicit user approval.** +4. **Dirty worktree:** Commit or stash your own files before pulling. Never stash everything (`git stash`) — only your files: `git stash push -- file1 file2`. +5. **Conflict resolution:** If merge conflicts, resolve and commit. Never force-push without user approval. +6. **Repo-local config:** Each clone must run the setup commands below to override any global/system rebase defaults. ### New Clone Setup @@ -738,8 +739,8 @@ uv run ruff check --fix . # Lint (Python only) uv run ruff format . # Format git add ... # Stage specific files (never git add -A) uv run git commit -m "type: concise summary" # Conventional format -git pull --no-rebase upstream main # Merge remote changes first -git push upstream main +git pull --no-rebase origin # Merge fork changes first +git push origin # Push to fork (NEVER upstream) ``` **Never stage:** auto-generated files (models.py, dd_models.py, physics_domain.py), gitignored files, `*_private.yaml` files. @@ -767,7 +768,7 @@ Commits in worktrees are NOT on `main` until merged. Always merge immediately: WORKTREE_HEAD=$(git rev-parse HEAD) cd /home/mcintos/Code/imas-codex git merge --no-ff $WORKTREE_HEAD -m "merge: worktree changes for " -git push upstream main``` +git push origin main``` ### Parallel Agents diff --git a/imas_codex/cli/__init__.py b/imas_codex/cli/__init__.py index a11be1f33..ead847213 100644 --- a/imas_codex/cli/__init__.py +++ b/imas_codex/cli/__init__.py @@ -70,6 +70,7 @@ def register_commands() -> None: from imas_codex.cli.llm_cli import llm from imas_codex.cli.release import release from imas_codex.cli.serve import serve + from imas_codex.cli.sn import sn from imas_codex.cli.tools import tools from imas_codex.cli.tunnel import tunnel @@ -82,6 +83,7 @@ def register_commands() -> None: main.add_command(discover) main.add_command(embed) main.add_command(imas) + main.add_command(sn) main.add_command(tools) main.add_command(host) main.add_command(facilities) diff --git a/imas_codex/cli/sn.py b/imas_codex/cli/sn.py new file mode 100644 index 000000000..e35487465 --- /dev/null +++ b/imas_codex/cli/sn.py @@ -0,0 +1,598 @@ +"""Standard name generation commands.""" + +from __future__ import annotations + +import logging + +import click +from rich.console import Console + +logger = logging.getLogger(__name__) +console = Console() + + +@click.group() +def sn() -> None: + """Standard name generation and management. + + \b + Build: + imas-codex sn build --source dd [--ids NAME] [--domain NAME] + imas-codex sn build --source signals --facility NAME + + \b + Status: + imas-codex sn status + """ + pass + + +@sn.command("build") +@click.option( + "--source", + type=click.Choice(["dd", "signals"]), + required=True, + help="Source to extract candidates from", +) +@click.option( + "--ids", + "ids_filter", + type=str, + default=None, + help="Filter to specific IDS (for DD source)", +) +@click.option( + "--domain", + "domain_filter", + type=str, + default=None, + help="Filter to physics domain", +) +@click.option( + "--facility", + type=str, + default=None, + help="Facility ID (required for signals source)", +) +@click.option( + "--cost-limit", + type=float, + default=5.0, + help="Maximum LLM cost in USD", +) +@click.option("--dry-run", is_flag=True, help="Preview extraction without LLM calls") +@click.option( + "--force", is_flag=True, help="Re-generate names for already-named sources" +) +@click.option( + "--limit", + type=int, + default=None, + help="Maximum number of DD paths to process", +) +@click.option( + "--review-model", + type=str, + default=None, + help="LLM model for cross-model review (default: reasoning model)", +) +@click.option("--skip-review", is_flag=True, help="Skip the cross-model review phase") +@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging") +@click.option("-q", "--quiet", is_flag=True, help="Suppress non-error output") +def sn_build( + source: str, + ids_filter: str | None, + domain_filter: str | None, + facility: str | None, + cost_limit: float, + dry_run: bool, + force: bool, + limit: int | None, + review_model: str | None, + skip_review: bool, + verbose: bool, + quiet: bool, +) -> None: + """Build standard names from a source. + + \b + Examples: + imas-codex sn build --source dd --ids equilibrium --dry-run + imas-codex sn build --source dd --domain magnetics --cost-limit 2 + imas-codex sn build --source signals --facility tcv + """ + # Validate: signals source requires facility + if source == "signals" and not facility: + raise click.UsageError("--facility is required when --source is signals") + + from imas_codex.discovery.base.llm import set_litellm_offline_env + + set_litellm_offline_env() + + from imas_codex.cli.discover.common import ( + DiscoveryConfig, + make_log_print, + run_discovery, + setup_logging, + use_rich_output, + ) + + use_rich = use_rich_output() + console_obj = setup_logging("sn", "sn", use_rich, verbose=verbose) + log_print = make_log_print("sn", console_obj) + + # Suppress noisy loggers + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + # Determine effective facility for state + effective_facility = facility if source == "signals" else "dd" + + log_print("\n[bold]Standard Name Build[/bold]") + log_print(f" Source: {source}") + if ids_filter: + log_print(f" IDS filter: {ids_filter}") + if domain_filter: + log_print(f" Domain filter: {domain_filter}") + if facility: + log_print(f" Facility: {facility}") + if dry_run: + log_print(" Mode: dry run") + if force: + log_print(" Force: re-generating all names") + if limit: + log_print(f" Limit: {limit} paths") + if skip_review: + log_print(" Review: skipped") + elif review_model: + log_print(f" Review model: {review_model}") + log_print(f" Cost limit: ${cost_limit:.2f}") + log_print("") + + from imas_codex.sn.pipeline import run_sn_build_engine + from imas_codex.sn.state import SNBuildState + + # Build progress display + display = None + if use_rich and not quiet: + try: + from imas_codex.sn.progress import SNProgressDisplay + + display = SNProgressDisplay( + source=source, + console=console_obj, + cost_limit=cost_limit, + mode_label="DRY RUN" if dry_run else None, + ) + except Exception: + logger.debug("Could not create progress display", exc_info=True) + + state = SNBuildState( + facility=effective_facility, + source=source, + ids_filter=ids_filter, + domain_filter=domain_filter, + facility_filter=facility, + cost_limit=cost_limit, + dry_run=dry_run, + force=force, + limit=limit, + skip_review=skip_review, + review_model=review_model, + ) + + if display: + display.set_engine_state(state) + + async def _run(stop_event, service_monitor): + if service_monitor: + state.service_monitor = service_monitor + await run_sn_build_engine( + state, + stop_event=stop_event, + on_worker_status=display.on_worker_status if display else None, + ) + return state.stats + + config = DiscoveryConfig( + facility=effective_facility, + domain="sn", + facility_config={}, + display=display, + check_graph=True, + check_embed=False, + check_ssh=False, + check_auth=source != "dd", # signals source might need auth + check_model=not dry_run, + model_section="language", + suppress_loggers=[ + "imas_codex.sn", + ], + verbose=verbose, + ) + + result = run_discovery(config, _run) + + # Print summary + if result: + extracted = result.get("extract_count", 0) + composed = result.get("compose_count", 0) + reviewed = result.get("review_accepted", composed) + validated = result.get("validate_valid", 0) + parts = [f"Extracted: {extracted}", f"Composed: {composed}"] + if not skip_review: + rejected = result.get("review_rejected", 0) + revised = result.get("review_revised", 0) + parts.append( + f"Reviewed: {reviewed} (rejected: {rejected}, revised: {revised})" + ) + parts.append(f"Validated: {validated}") + log_print(", ".join(parts)) + if dry_run: + log_print("(dry run — no LLM calls or graph writes)") + + +@sn.command("benchmark") +@click.option( + "--source", + type=click.Choice(["dd", "signals"]), + default="dd", + help="Source to extract candidates from", +) +@click.option( + "--ids", + "ids_filter", + type=str, + default=None, + help="Filter to specific IDS (for DD source)", +) +@click.option( + "--domain", + "domain_filter", + type=str, + default=None, + help="Filter to physics domain", +) +@click.option( + "--facility", + type=str, + default=None, + help="Facility ID (required for signals source)", +) +@click.option( + "--models", + type=str, + required=True, + help="Comma-separated model list (e.g. 'claude-sonnet-4,gpt-4o')", +) +@click.option( + "--max-candidates", + type=int, + default=50, + help="Maximum extraction candidates", +) +@click.option( + "--runs", + type=int, + default=1, + help="Runs per model for consistency check", +) +@click.option( + "--temperature", + type=float, + default=0.0, + help="LLM temperature (0.0 for reproducibility)", +) +@click.option( + "--output", + type=click.Path(), + default=None, + help="JSON report output path", +) +@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging") +def sn_benchmark( + source: str, + ids_filter: str | None, + domain_filter: str | None, + facility: str | None, + models: str, + max_candidates: int, + runs: int, + temperature: float, + output: str | None, + verbose: bool, +) -> None: + """Benchmark LLM models on standard name generation. + + Runs a fixed dataset through multiple models and compares results + on grammar validity, reference overlap, cost, and speed. + + \b + Examples: + imas-codex sn benchmark --models claude-sonnet-4,gpt-4o --ids equilibrium + imas-codex sn benchmark --models claude-sonnet-4 --max-candidates 20 -v + imas-codex sn benchmark --models gpt-4o --output report.json + """ + if verbose: + logging.basicConfig(level=logging.DEBUG) + else: + logging.basicConfig(level=logging.WARNING) + + # Parse model list + model_list = [m.strip() for m in models.split(",") if m.strip()] + if not model_list: + raise click.UsageError("--models must contain at least one model name") + + from imas_codex.sn.benchmark import ( + BenchmarkConfig, + render_comparison_table, + run_benchmark, + ) + + config = BenchmarkConfig( + models=model_list, + source=source, + ids_filter=ids_filter, + domain_filter=domain_filter, + facility=facility, + max_candidates=max_candidates, + runs_per_model=runs, + temperature=temperature, + ) + + console.print("[bold]SN Benchmark[/bold]") + console.print(f" Models: {', '.join(model_list)}") + console.print(f" Source: {source}") + if ids_filter: + console.print(f" IDS filter: {ids_filter}") + if domain_filter: + console.print(f" Domain filter: {domain_filter}") + console.print(f" Max candidates: {max_candidates}") + console.print(f" Runs per model: {runs}") + console.print(f" Temperature: {temperature}") + console.print() + + from imas_codex.cli.utils import run_async + + report = run_async(run_benchmark(config)) + + # Display comparison table + render_comparison_table(report) + + # Save JSON report + if output is None: + ts = report.timestamp.replace(":", "").replace("-", "")[:15] + output = f"sn_benchmark_{ts}.json" + + from pathlib import Path + + out_path = Path(output) + out_path.write_text(report.to_json()) + console.print(f"\n[green]Report saved:[/green] {out_path}") + + +@sn.command("status") +def sn_status() -> None: + """Show standard name statistics.""" + from imas_codex.graph.client import GraphClient + + try: + with GraphClient() as gc: + result = gc.query( + """ + MATCH (sn:StandardName) + RETURN count(sn) AS total, + count(CASE WHEN sn.source = 'dd' THEN 1 END) AS from_dd, + count(CASE WHEN sn.source = 'signals' THEN 1 END) AS from_signals, + count(CASE WHEN sn.source = 'manual' THEN 1 END) AS from_manual + """ + ) + row = next(iter(result), None) + if row: + console.print(f"[bold]Standard Names:[/bold] {row['total']}") + console.print(f" From DD: {row['from_dd']}") + console.print(f" From signals: {row['from_signals']}") + console.print(f" From manual: {row['from_manual']}") + else: + console.print("No standard names in graph") + except Exception as e: + console.print(f"[red]Error:[/red] {e}") + + +@sn.command("publish") +@click.option( + "--ids", + "ids_filter", + type=str, + default=None, + help="Filter to specific IDS name", +) +@click.option( + "--domain", + "domain_filter", + type=str, + default=None, + help="Filter to physics domain (applied to tags)", +) +@click.option( + "--output-dir", + type=click.Path(), + default="sn_catalog_output", + help="Directory for YAML files", +) +@click.option( + "--group-by", + type=click.Choice(["ids", "domain", "confidence"]), + default="ids", + help="Batching strategy for PR grouping", +) +@click.option( + "--confidence-min", + type=float, + default=0.0, + help="Minimum confidence threshold (0.0-1.0)", +) +@click.option( + "--catalog-dir", + type=click.Path(exists=False), + default=None, + help="Existing catalog directory for duplicate checking", +) +@click.option( + "--create-pr", + is_flag=True, + help="Create GitHub PR (requires gh CLI)", +) +@click.option( + "--catalog-repo", + type=str, + default="iterorganization/imas-standard-names-catalog", + help="Target GitHub repo for PR creation", +) +@click.option("--dry-run", is_flag=True, help="Preview without writing files") +@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging") +def sn_publish( + ids_filter: str | None, + domain_filter: str | None, + output_dir: str, + group_by: str, + confidence_min: float, + catalog_dir: str | None, + create_pr: bool, + catalog_repo: str, + dry_run: bool, + verbose: bool, +) -> None: + """Publish validated standard names to YAML catalog files. + + \b + Reads StandardName nodes from the graph, converts them to YAML + files matching the imas-standard-names-catalog format, and + optionally creates batched GitHub pull requests. + + \b + Examples: + imas-codex sn publish --dry-run + imas-codex sn publish --ids equilibrium --output-dir catalog/ + imas-codex sn publish --group-by confidence --confidence-min 0.8 + imas-codex sn publish --create-pr --catalog-repo org/repo + """ + from pathlib import Path + + if verbose: + logging.basicConfig(level=logging.DEBUG) + + console.print("\n[bold]Standard Name Publish[/bold]") + if ids_filter: + console.print(f" IDS filter: {ids_filter}") + if domain_filter: + console.print(f" Domain filter: {domain_filter}") + console.print(f" Output: {output_dir}") + console.print(f" Group by: {group_by}") + console.print(f" Confidence min: {confidence_min}") + if dry_run: + console.print(" Mode: [yellow]dry run[/yellow]") + console.print("") + + # Step 1: Load validated names from graph + try: + from imas_codex.sn.graph_ops import get_validated_standard_names + + records = get_validated_standard_names( + ids_filter=ids_filter, + confidence_min=confidence_min, + ) + except Exception as e: + console.print(f"[red]Error reading from graph:[/red] {e}") + raise SystemExit(1) from e + + if not records: + console.print("[yellow]No validated standard names found in graph.[/yellow]") + return + + console.print(f" Loaded [bold]{len(records)}[/bold] validated names from graph") + + # Step 2: Convert to publish entries + from imas_codex.sn.publish import ( + check_catalog_duplicates, + create_catalog_pr, + generate_catalog_files, + graph_records_to_entries, + make_publish_batches, + ) + + entries = graph_records_to_entries(records) + + # Apply domain filter on tags if specified + if domain_filter: + entries = [e for e in entries if domain_filter in e.tags] + console.print(f" After domain filter: [bold]{len(entries)}[/bold] entries") + + if not entries: + console.print("[yellow]No entries after filtering.[/yellow]") + return + + # Step 3: Check for duplicates + catalog_path = Path(catalog_dir) if catalog_dir else None + new_entries, duplicates = check_catalog_duplicates(entries, catalog_path) + + if duplicates: + console.print(f" Skipping [yellow]{len(duplicates)}[/yellow] duplicate(s)") + entries = new_entries + + if not entries: + console.print( + "[yellow]All entries are duplicates — nothing to publish.[/yellow]" + ) + return + + # Step 4: Create batches + batches = make_publish_batches(entries, group_by) + + # Step 5: Print summary table + console.print(f"\n[bold]Publish Summary[/bold] ({len(entries)} entries)") + console.print("") + for batch in batches: + console.print( + f" [bold]{batch.group_key}[/bold]: " + f"{len(batch.entries)} entries " + f"(confidence: {batch.confidence_tier})" + ) + if verbose: + for entry in batch.entries: + conf = f"{entry.provenance.confidence:.2f}" + console.print(f" - {entry.name} [{conf}]") + + # Step 6: Generate YAML files + if dry_run: + console.print( + f"\n[yellow]Dry run — would write {len(entries)} files to {output_dir}[/yellow]" + ) + else: + out = Path(output_dir) + written = generate_catalog_files(entries, out) + console.print(f"\n[green]Wrote {len(written)} YAML files to {out}[/green]") + + # Step 7: Optionally create PRs + if create_pr: + for batch in batches: + branch = f"sn/{batch.group_key}/{batch.confidence_tier}" + branch = branch.replace(" ", "-").lower() + yaml_files = ( + [Path(output_dir) / f"{e.name}.yaml" for e in batch.entries] + if not dry_run + else [] + ) + pr_url = create_catalog_pr( + batch=batch, + catalog_repo=catalog_repo, + branch_name=branch, + yaml_files=yaml_files, + dry_run=dry_run, + ) + if pr_url: + console.print(f" PR: {pr_url}") + elif dry_run: + console.print( + f" [yellow]Would create PR for {batch.group_key}[/yellow]" + ) diff --git a/imas_codex/core/paths.py b/imas_codex/core/paths.py index 54be7d439..a9cf151c6 100644 --- a/imas_codex/core/paths.py +++ b/imas_codex/core/paths.py @@ -31,3 +31,65 @@ def strip_path_annotations(path: str) -> str: path = _PAREN_INDEX_RE.sub("", path) path = _BRACKET_INDEX_RE.sub("", path) return path + + +def _looks_like_path(text: str) -> bool: + """Return True if *text* looks like an IMAS path, not natural language. + + IMAS paths are space-free, composed of lowercase ``[a-z0-9_]`` segments + separated by dots or slashes. Natural language contains spaces, + mixed-case tokens like ``eV``, or purely numeric tokens like version + strings ``3.39.0``. + """ + if " " in text: + return False + # Must have at least one separator + if "." not in text and "/" not in text: + return False + # All segments must be lowercase identifiers (IMAS never uses uppercase) + segments = re.split(r"[./]", text) + if not all(re.fullmatch(r"[a-z0-9_]*", seg) for seg in segments): + return False + # At least one segment must contain a letter (reject "3.39.0") + return any(re.search(r"[a-z]", seg) for seg in segments) + + +def normalize_imas_path(path: str) -> str: + """Normalize an IMAS path: dot→slash conversion, annotation stripping. + + Handles all common user input formats: + - Dot notation: ``equilibrium.time_slice.profiles_1d.psi`` + - Mixed: ``equilibrium.time_slice/profiles_1d`` + - Index annotations: ``time_slice(i1)/flux[:]/data`` + - Dot-notation with annotations: ``time_slice(itime).profiles_1d.psi`` + - Clean paths pass through unchanged + + Natural language queries (containing spaces, punctuation like ``e.g.``, + or sentence-ending dots) are returned stripped but otherwise unchanged + — dot→slash replacement is only applied to path-like inputs. + + Examples:: + + >>> normalize_imas_path("equilibrium.time_slice.profiles_1d.psi") + 'equilibrium/time_slice/profiles_1d/psi' + >>> normalize_imas_path("equilibrium.time_slice/profiles_1d") + 'equilibrium/time_slice/profiles_1d' + >>> normalize_imas_path("flux_loop(i1)/flux/data(:)") + 'flux_loop/flux/data' + >>> normalize_imas_path(" equilibrium/time_slice ") + 'equilibrium/time_slice' + >>> normalize_imas_path("electron temperature e.g. in eV") + 'electron temperature e.g. in eV' + >>> normalize_imas_path("Find B0.") + 'Find B0.' + """ + path = path.strip() + # Strip annotations first so _looks_like_path sees clean segments + path = strip_path_annotations(path) + # Collapse any double slashes from annotation removal + while "//" in path: + path = path.replace("//", "/") + # Only convert dots when input looks like an IMAS path + if "." in path and _looks_like_path(path): + path = path.replace(".", "/") + return path.strip("/") diff --git a/imas_codex/llm/prompts/sn/compose_dd.md b/imas_codex/llm/prompts/sn/compose_dd.md new file mode 100644 index 000000000..9206530bb --- /dev/null +++ b/imas_codex/llm/prompts/sn/compose_dd.md @@ -0,0 +1,39 @@ +--- +name: sn/compose_dd +description: Dynamic user prompt for SN composition — per-batch DD paths +used_by: imas_codex.sn.workers.compose_worker +task: composition +dynamic: true +schema_needs: [] +--- + +Generate standard names for the following IMAS Data Dictionary paths. + +## Context + +**IDS:** {{ ids_name }} +{% if cluster_context %} +{{ cluster_context }} +{% endif %} + +{% if existing_names %} +## Existing Standard Names (reuse when applicable) + +These names already exist. **Reuse** them when the DD path measures the same +quantity — do not create a duplicate with different wording. + +{% for name in existing_names %} +- {{ name }} +{% endfor %} +{% endif %} + +## DD Paths to Name + +{% for item in items %} +### {{ item.path }} +- **Description:** {{ item.description }} +- **Units:** {{ item.units or 'unspecified' }} +- **Data type:** {{ item.data_type or 'unspecified' }} +{% if item.cluster_label %}- **Cluster:** {{ item.cluster_label }}{% endif %} + +{% endfor %} diff --git a/imas_codex/llm/prompts/sn/compose_signals.md b/imas_codex/llm/prompts/sn/compose_signals.md new file mode 100644 index 000000000..445298dc1 --- /dev/null +++ b/imas_codex/llm/prompts/sn/compose_signals.md @@ -0,0 +1,110 @@ +--- +name: sn/compose_signals +description: Generate standard names for facility signal descriptions +used_by: imas_codex.sn.workers.compose_worker +task: composition +dynamic: true +--- + +You are a physics nomenclature expert generating standard names for measured quantities at a fusion research facility. + +## Standard Name Grammar + +A standard name is composed from these optional fields. Only use values from the valid lists below. + +### physical_base (required) +The root physics quantity as a free-form snake_case token. Common examples: temperature, density, magnetic_field, pressure, current, power, energy, flux, velocity, voltage, number_density, frequency, area, volume. + +### subject +What species or population is being measured. +Valid: {{ subjects | join(', ') }} + +### position +Where in the plasma or device the quantity is measured. +Valid: {{ positions | join(', ') }} + +### component +Vector or tensor component. +Valid: {{ components | join(', ') }} + +### coordinate +Coordinate system component (uses same enum as component). +Valid: {{ coordinates | join(', ') }} + +### process +Physical process or mechanism. +Valid: {{ processes | join(', ') }} + +### transformation +Mathematical transformation applied to the quantity. +Valid: {{ transformations | join(', ') }} + +### geometric_base +Geometric quantity (use instead of physical_base for geometric data). +Valid: {{ geometric_bases | join(', ') }} + +### object +Device component or diagnostic instrument. +Valid: {{ objects | join(', ') }} + +### binary_operator +For compound names combining two quantities. +Valid: {{ binary_operators | join(', ') }} + +## Composition Rules + +1. Every name must have either a `physical_base` or a `geometric_base` (not both) +2. The composed name follows the pattern: `[subject]_[physical_base]_[modifiers]` +3. Examples: + - electron_temperature → `{"physical_base": "temperature", "subject": "electron"}` + - plasma_current → `{"physical_base": "current"}` + - line_integrated_density → `{"physical_base": "density", "transformation": "line_integrated"}` + - toroidal_magnetic_field_at_magnetic_axis → `{"physical_base": "magnetic_field", "component": "toroidal", "position": "magnetic_axis"}` +4. Use existing standard names as reference for naming conventions +5. Signal descriptions may be terse or use facility-specific jargon — interpret them using your physics knowledge +6. Skip signals that are status flags, configuration parameters, or timing references + +{% if existing_names %} +## Existing Standard Names (do not duplicate) +{% for name in existing_names %} +- {{ name }} +{% endfor %} +{% endif %} + +## Signals to Name + +Facility: {{ facility }}, Domain: {{ domain }} + +{% for item in items %} +### Signal: {{ item.signal_id }} +- Description: {{ item.description }} +- Units: {{ item.units or 'unspecified' }} +- Physics domain: {{ item.physics_domain or 'unspecified' }} + +{% endfor %} + +## Output Format + +For each signal that represents a distinct physics quantity, generate a standard name. Return a JSON object matching this schema: + +```json +{ + "candidates": [ + { + "source_id": "signal_id_here", + "standard_name": "electron_temperature", + "fields": {"physical_base": "temperature", "subject": "electron"}, + "confidence": 0.85, + "reason": "Thomson scattering electron temperature measurement" + } + ], + "skipped": ["status_flag_signal", "timing_reference_signal"] +} +``` + +- **source_id**: The signal ID +- **standard_name**: The composed name string (snake_case) +- **fields**: Dict of grammar fields used (only include non-null fields) +- **confidence**: Float 0.0-1.0 — higher when the signal clearly maps to a single physics quantity +- **reason**: Brief justification for the name choice +- **skipped**: List of signal IDs that are not distinct physics quantities diff --git a/imas_codex/llm/prompts/sn/compose_system.md b/imas_codex/llm/prompts/sn/compose_system.md new file mode 100644 index 000000000..5752fcf96 --- /dev/null +++ b/imas_codex/llm/prompts/sn/compose_system.md @@ -0,0 +1,110 @@ +--- +name: sn/compose_system +description: Static system prompt for SN composition — prompt-cached via OpenRouter +used_by: imas_codex.sn.workers.compose_worker +task: composition +dynamic: false +schema_needs: [] +--- + +You are a physics nomenclature expert generating IMAS standard names for fusion plasma quantities. + +## Canonical Composition Pattern + +{{ canonical_pattern }} + +### Segment Order + +{{ segment_order }} + +### Template Application + +{{ template_rules }} + +### Exclusive Pairs + +These segment pairs are mutually exclusive — never use both in the same name: +{% for pair in exclusive_pairs %} +- **{{ pair[0] }}** and **{{ pair[1] }}** +{% endfor %} + +## Vocabulary Reference + +{% for section in vocabulary_sections %} +### {{ section.segment }}{% if section.is_open %} (open vocabulary){% endif %} + +{{ section.description }} +{% if section.template %} +Template: `{{ section.template }}` +{% endif %} +{% if section.exclusive_with %} +Exclusive with: {{ section.exclusive_with | join(', ') }} +{% endif %} +{% if section.tokens %} +Valid tokens: {{ section.tokens | join(', ') }} +{% endif %} +{% if section.is_open %} +Use any physics quantity in snake_case (e.g., temperature, density, magnetic_field, pressure). +{% endif %} + +{% endfor %} + +## Segment Descriptions + +{% for seg_name, seg_desc in segment_descriptions.items() %} +### {{ seg_name }} + +{{ seg_desc }} + +{% endfor %} + +## Curated Examples + +Learn from these validated standard names: + +{% for ex in examples %} +### {{ ex.name }} +- **Category:** {{ ex.category }} +- **Kind:** {{ ex.get('kind', 'scalar') }} +- **Unit:** {{ ex.get('unit', 'unspecified') }} +- **Description:** {{ ex.description }} +{% endfor %} + +## Tokamak Parameter Ranges + +Use these typical values to ground documentation and confidence assessment. +Do NOT invent parameter values — use only what is listed here. + +{% for machine_name, machine in tokamak_ranges.items() %} +### {{ machine_name }} +{% if machine.get('geometry') %} +Geometry: R₀={{ machine.geometry.get('major_radius', {}).get('value', '?') }}m, a={{ machine.geometry.get('minor_radius', {}).get('value', '?') }}m, κ={{ machine.geometry.get('elongation', {}).get('value', '?') }} +{% endif %} +{% if machine.get('physics') %} +Physics: B_T={{ machine.physics.get('toroidal_magnetic_field', {}).get('value', '?') }}T, I_p={{ machine.physics.get('plasma_current', {}).get('value', '?') }}MA +{% endif %} +{% endfor %} + +## Composition Rules + +1. Every name MUST have either a `physical_base` or a `geometric_base` (never both) +2. Follow the canonical pattern strictly — segments must appear in the correct order +3. Use only valid tokens from the vocabulary lists above +4. `physical_base` is open vocabulary (any physics quantity in snake_case) +5. `geometric_base` is restricted to the enumerated tokens +6. **Reuse existing standard names** when the DD path measures the same quantity +7. Skip paths that are: array indices, metadata/timestamps, structural containers, coordinate grids (rho_tor_norm, psi, etc.) +8. Set confidence < 0.5 when the mapping is ambiguous or multiple names could apply + +## Output Format + +Return a JSON object with: +- `candidates`: array of standard name compositions +- `skipped`: array of source_ids that are not distinct physics quantities + +Each candidate has: +- `source_id`: full DD path (e.g., "equilibrium/time_slice/profiles_1d/psi") +- `standard_name`: the composed name in snake_case +- `fields`: dict of grammar fields used (only non-null fields) +- `confidence`: float 0.0-1.0 +- `reason`: brief justification diff --git a/imas_codex/llm/prompts/sn/review.md b/imas_codex/llm/prompts/sn/review.md new file mode 100644 index 000000000..898cf0e62 --- /dev/null +++ b/imas_codex/llm/prompts/sn/review.md @@ -0,0 +1,130 @@ +--- +name: sn/review +description: Cross-model review of standard name candidates +used_by: imas_codex.sn.workers.review_worker +task: review +dynamic: true +--- + +You are an independent reviewer auditing standard name candidates for fusion plasma quantities. Your role is to catch errors in grammar, semantics, and naming conventions that the original composer may have missed. + +## Standard Name Grammar + +A standard name is composed from these optional fields. Only values from the valid lists are allowed. + +### physical_base (required unless geometric_base is used) +The root physics quantity as a free-form snake_case token. Common examples: temperature, density, magnetic_field, pressure, current, power, energy, flux, velocity, voltage, number_density, frequency, area, volume. + +### subject +What species or population is being measured. +Valid: {{ subjects | join(', ') }} + +### position +Where in the plasma or device the quantity is measured. +Valid: {{ positions | join(', ') }} + +### component +Vector or tensor component. +Valid: {{ components | join(', ') }} + +### coordinate +Coordinate system component (uses same enum as component). +Valid: {{ coordinates | join(', ') }} + +### process +Physical process or mechanism. +Valid: {{ processes | join(', ') }} + +### transformation +Mathematical transformation applied to the quantity. +Valid: {{ transformations | join(', ') }} + +### geometric_base +Geometric quantity (use instead of physical_base for geometric data). +Valid: {{ geometric_bases | join(', ') }} + +### object +Device component or diagnostic instrument. +Valid: {{ objects | join(', ') }} + +### binary_operator +For compound names combining two quantities. +Valid: {{ binary_operators | join(', ') }} + +## Review Criteria + +For each candidate, evaluate: + +1. **Grammar correctness**: Does the name use only valid enum values from the lists above? Are the fields consistent with the grammar rules? +2. **Semantic accuracy**: Does the standard name accurately represent the source quantity description? Is the physical_base appropriate? +3. **Naming conventions**: Does the name follow snake_case style? Is it concise but unambiguous? +4. **Unit consistency**: If units are provided, is the physical_base consistent with those units? +5. **Duplicate avoidance**: Is the name unique relative to existing names? + +## Verdicts + +- **accept**: The name is correct, follows conventions, and accurately represents the source quantity. +- **reject**: The name has fundamental issues (wrong physics, invalid grammar, meaningless). Provide clear reasons. +- **revise**: The name has fixable issues. Provide `revised_name` and `revised_fields` with the corrected version. + +{% if existing_names %} +## Existing Standard Names (must not duplicate) +{% for name in existing_names %} +- {{ name }} +{% endfor %} +{% endif %} + +## Candidates to Review + +{% for item in items %} +### Candidate {{ loop.index }} +- **Standard name**: {{ item.id }} +- **Source ID**: {{ item.source_id }} +- **Physical base**: {{ item.physical_base or 'unspecified' }} +- **Subject**: {{ item.subject or 'none' }} +- **Component**: {{ item.component or 'none' }} +- **Position**: {{ item.position or 'none' }} +- **Units**: {{ item.units or 'unspecified' }} +- **Description**: {{ item.description or 'none' }} + +{% endfor %} + +## Output Format + +Return a JSON object matching this schema: + +```json +{ + "reviews": [ + { + "source_id": "path/to/quantity", + "standard_name": "electron_temperature", + "verdict": "accept", + "confidence": 0.95, + "reason": "Name correctly captures the physics quantity", + "revised_name": null, + "revised_fields": null, + "issues": [] + }, + { + "source_id": "path/to/other", + "standard_name": "bad_name", + "verdict": "revise", + "confidence": 0.8, + "reason": "Subject should be 'ion' not 'ions'", + "revised_name": "ion_temperature", + "revised_fields": {"physical_base": "temperature", "subject": "ion"}, + "issues": ["Invalid subject value 'ions'"] + } + ] +} +``` + +- **source_id**: The source entity ID from the candidate +- **standard_name**: The name being reviewed (as provided) +- **verdict**: One of "accept", "reject", "revise" +- **confidence**: Float 0.0-1.0 — higher when the review is decisive +- **reason**: Brief justification for the verdict +- **revised_name**: Only for "revise" verdicts — the corrected name string +- **revised_fields**: Only for "revise" verdicts — dict of corrected grammar fields +- **issues**: List of specific problems found (empty list if none) diff --git a/imas_codex/llm/search_formatters.py b/imas_codex/llm/search_formatters.py index 965c0e6de..6128a2080 100644 --- a/imas_codex/llm/search_formatters.py +++ b/imas_codex/llm/search_formatters.py @@ -817,7 +817,9 @@ def format_check_report(result: Any) -> str: if meta: parts.append(f" {' | '.join(meta)}") else: - if item.suggestion: + if item.suggestions and len(item.suggestions) > 1: + parts.append(f" Suggestions: {', '.join(item.suggestions)}") + elif item.suggestion: parts.append(f" Suggestion: {item.suggestion}") if item.migration: parts.append(f" Migration: {item.migration}") @@ -931,11 +933,29 @@ def format_overview_report(result: Any) -> str: parts: list[str] = [result.content, ""] - if result.physics_domains: - parts.append(f"**Physics domains**: {', '.join(result.physics_domains)}") + # High-level aggregates (when no query — overview mode) + domain_summary = getattr(result, "domain_summary", None) + lifecycle_summary = getattr(result, "lifecycle_summary", None) + unit_statistics = getattr(result, "unit_statistics", None) + + if domain_summary: + parts.append("### Physics Domains\n") + for domain, stats in domain_summary.items(): + parts.append( + f" {domain}: {stats['ids_count']} IDS, {stats['path_count']} paths" + ) + parts.append("") + + if lifecycle_summary: + parts.append( + "**Lifecycle**: " + + ", ".join(f"{k}: {v}" for k, v in lifecycle_summary.items()) + ) + parts.append("") if result.ids_statistics: - parts.append(f"\n### IDS Summary ({len(result.available_ids)} IDS)\n") + label = "Top IDS" if domain_summary else "IDS Summary" + parts.append(f"\n### {label} ({len(result.available_ids)} IDS)\n") # Sort by path count descending sorted_ids = sorted( result.ids_statistics.items(), @@ -956,6 +976,11 @@ def format_overview_report(result: Any) -> str: if result.mcp_tools: parts.append(f"\n**Available tools**: {', '.join(result.mcp_tools)}") + if unit_statistics: + parts.append("\n### Unit Distribution (top 20)\n") + for unit, count in unit_statistics.items(): + parts.append(f" {unit}: {count} paths") + return "\n".join(parts) @@ -1100,13 +1125,9 @@ def format_search_dd_report(result: Any, cluster_result: Any | None = None) -> s parts.append(f" Introduced: DD {hit.introduced_after_version}") if hit.keywords: parts.append(f" Keywords: {', '.join(hit.keywords)}") - - # Cluster labels - if getattr(hit, "cluster_labels", None): + if hit.cluster_labels: parts.append(f" Clusters: {', '.join(hit.cluster_labels)}") - - # See-also cross-IDS siblings - if getattr(hit, "see_also", None): + if hit.see_also: shown = hit.see_also[:3] extra = len(hit.see_also) - 3 line = f" See also: {', '.join(shown)}" @@ -1163,7 +1184,7 @@ def format_search_dd_report(result: Any, cluster_result: Any | None = None) -> s def format_path_context_report(result: dict[str, Any]) -> str: - """Format get_dd_path_context result into readable text.""" + """Format get_imas_path_context result into readable text.""" tool_error = _format_tool_error(result) if tool_error: return tool_error @@ -1234,7 +1255,7 @@ def format_path_context_report(result: dict[str, Any]) -> str: def format_structure_report(result: dict[str, Any]) -> str: - """Format analyze_dd_structure result into readable text.""" + """Format analyze_imas_structure result into readable text.""" tool_error = _format_tool_error(result) if tool_error: return tool_error @@ -1301,6 +1322,90 @@ def format_structure_report(result: dict[str, Any]) -> str: return "\n".join(parts) +def format_ids_structure_report(result: dict[str, Any]) -> str: + """Format get_ids_structure result into a compact, rich overview.""" + if isinstance(result, dict) and result.get("error"): + return f"Error: {result['error']}" + + parts: list[str] = [] + ids_name = result.get("ids_name", "") + desc = result.get("description", "") + domain = result.get("physics_domain", "") + lifecycle = result.get("lifecycle_status", "") + + parts.append(f"## {ids_name}") + if desc: + parts.append(f"{desc}\n") + meta = [] + if domain: + meta.append(f"Domain: {domain}") + if lifecycle: + meta.append(f"Lifecycle: {lifecycle}") + if meta: + parts.append(" | ".join(meta)) + + # Metrics + m = result.get("metrics", {}) + parts.append( + f"\n**Paths**: {m.get('total_paths', 0)} total " + f"({m.get('leaf_count', 0)} leaf, {m.get('structure_count', 0)} structure) " + f"| Max depth: {m.get('max_depth', 0)}" + ) + + # Top-level sections + sections = result.get("top_sections", []) + if sections: + parts.append(f"\n### Top-Level Sections ({len(sections)})\n") + for s in sections: + name = s.get("name") or s.get("id", "").split("/")[-1] + dtype = s.get("data_type", "") + doc = s.get("doc", "") + line = f" **{name}** [{dtype}]" + if doc: + line += f" — {doc}" + parts.append(line) + + # Data types + dtypes = result.get("data_types", {}) + if dtypes: + parts.append("\n### Data Types\n") + parts.append(" " + " | ".join(f"{k}: {v}" for k, v in dtypes.items())) + + # Clusters + clusters = result.get("clusters", []) + if clusters: + parts.append(f"\n### Semantic Clusters ({len(clusters)})\n") + for c in clusters: + parts.append(f" {c['label']} [{c['scope']}] ({c['members']} paths)") + + # Identifier schemas + idents = result.get("identifier_schemas", []) + if idents: + parts.append(f"\n### Identifier Schemas ({len(idents)})\n") + for i in idents: + examples = ", ".join(i.get("examples", [])[:2]) + parts.append(f" {i['schema']} (×{i['usage_count']}) e.g. {examples}") + + # COCOS + cocos = result.get("cocos_fields", []) + if cocos: + parts.append(f"\n### COCOS Fields ({len(cocos)})\n") + for c in cocos: + parts.append(f" `{c['path']}` ({c['label']})") + + # Coordinate arrays (compact) + coords = result.get("coordinate_arrays", []) + if coords: + parts.append(f"\n### Coordinate Arrays ({len(coords)})\n") + for ca in coords[:10]: + clist = ", ".join(ca.get("coordinates", [])) + parts.append(f" `{ca['path']}` → [{clist}]") + if len(coords) > 10: + parts.append(f" ... and {len(coords) - 10} more") + + return "\n".join(parts) + + def format_export_ids_report(result: dict[str, Any]) -> str: """Format export_imas_ids result into readable text.""" tool_error = _format_tool_error(result) @@ -1410,73 +1515,72 @@ def format_export_domain_report(result: Any) -> str: return "\n".join(parts) -def format_cocos_fields_report(result: Any) -> str: - """Format get_cocos_fields result into readable text.""" - err = _format_tool_error(result) - if err: - return err - - tt_map = result.get("transformation_types", {}) +def format_cocos_fields_report(result: dict[str, Any]) -> str: + """Format COCOS fields result into readable text.""" + parts: list[str] = [] total = result.get("total_fields", 0) filters = result.get("filters", {}) + tt_filter = filters.get("transformation_type") + ids_filter = filters.get("ids_filter") - parts = [f"## COCOS-Dependent Fields ({total} total)"] - - filter_parts = [] - if filters.get("transformation_type"): - filter_parts.append(f"type={filters['transformation_type']}") - if filters.get("ids_filter"): - filter_parts.append(f"ids={filters['ids_filter']}") - if filters.get("dd_version"): - filter_parts.append(f"dd_version={filters['dd_version']}") - if filter_parts: - parts.append(f"Filters: {', '.join(filter_parts)}") + header = f"## COCOS-Dependent Fields ({total} total)" + if tt_filter: + header += f" — type: {tt_filter}" + if ids_filter: + header += f" — IDS: {ids_filter}" + parts.append(header) parts.append("") - for tt, info in sorted(tt_map.items()): + for tt, info in (result.get("transformation_types") or {}).items(): count = info.get("count", 0) - fields = info.get("fields", []) parts.append(f"### {tt} ({count} fields)") - for f in fields: - path = f.get("path", "") - ids = f.get("ids", "") - parts.append(f" - `{path}` ({ids})") + for field in info.get("fields", []): + path = field.get("path", "") + parts.append(f" `{path}`") parts.append("") return "\n".join(parts) -def format_dd_changelog_report(result: Any) -> str: - """Format get_dd_changelog result into readable text.""" - err = _format_tool_error(result) - if err: - return err +def format_dd_changelog_report(result: dict[str, Any]) -> str: + """Format DD changelog/volatility result into readable text.""" + if "error" in result: + return f"Error: {result['error']}" parts: list[str] = [] - header = "## DD Path Volatility Ranking" + total = result.get("total", 0) ids_filter = result.get("ids_filter") + version_range = result.get("version_range") + limit = result.get("limit", 50) + + header = f"## DD Changelog — {total} most volatile paths" if ids_filter: header += f" (IDS: {ids_filter})" - version_range = result.get("version_range") - if version_range: - vr = version_range - header += f" ({vr.get('from', '')} → {vr.get('to', '')})" parts.append(header) - parts.append( - f"\nTotal: {result.get('total', 0)} paths (limit {result.get('limit', 50)})\n" - ) - for row in result.get("results", []): + if version_range: + fr = version_range.get("from", "") + to = version_range.get("to", "") + if fr or to: + parts.append(f"Version range: {fr or 'earliest'} → {to or 'latest'}") + parts.append("") + + parts.append("| Rank | Path | IDS | Changes | Types | Renamed | Score |") + parts.append("|------|------|-----|---------|-------|---------|-------|") + + for i, row in enumerate(result.get("results", []), 1): path = row.get("path", "") - score = row.get("volatility_score", 0) + ids_name = row.get("ids", "") changes = row.get("change_count", 0) types = row.get("change_types", []) - renamed = row.get("was_renamed", 0) - line = f" - `{path}` — score={score}, changes={changes}" - if types: - line += f", types=[{', '.join(types)}]" - if renamed: - line += " (renamed)" - parts.append(line) + renamed = "✓" if row.get("was_renamed") else "" + score = row.get("volatility_score", 0) + type_str = ", ".join(str(t) for t in types if t) if types else "" + parts.append( + f"| {i} | `{path}` | {ids_name} | {changes} | {type_str} | {renamed} | {score} |" + ) + + if total >= limit: + parts.append(f"\n*Showing top {limit} — use `limit` to see more.*") return "\n".join(parts) diff --git a/imas_codex/llm/server.py b/imas_codex/llm/server.py index 7bcf1f8c2..472bdddae 100644 --- a/imas_codex/llm/server.py +++ b/imas_codex/llm/server.py @@ -1216,7 +1216,7 @@ def export_imas_ids( try: tools = _get_imas_tools() result = _run_async( - tools.structure_tool.export_imas_ids( + tools.structure_tool.export_dd_ids( ids_name=ids_name, leaf_only=leaf_only, dd_version=dd_version, @@ -1244,7 +1244,7 @@ def export_imas_domain( try: tools = _get_imas_tools() result = _run_async( - tools.structure_tool.export_imas_domain( + tools.structure_tool.export_dd_domain( domain=domain, ids_filter=ids_filter, dd_version=dd_version, @@ -2658,7 +2658,7 @@ def fetch_dd_error_fields( """ tools = _get_imas_tools() result = _run_async( - tools.path_tool.fetch_dd_error_fields(path=path, dd_version=dd_version) + tools.path_tool.fetch_error_fields(path=path, dd_version=dd_version) ) return _format_error_fields_report(result) @@ -2696,7 +2696,10 @@ def list_dd_paths( or lifecycle_filter is not None ): logger.debug( - "physics_domain/node_type/lifecycle_filter not yet implemented in backend, ignoring" + "Applying filters: physics_domain=%s node_type=%s lifecycle=%s", + physics_domain, + node_type, + lifecycle_filter, ) tools = _get_imas_tools() result = _run_async( @@ -2705,7 +2708,9 @@ def list_dd_paths( leaf_only=leaf_only, max_paths=max_paths, dd_version=dd_version, - # physics_domain, node_type, lifecycle_filter not yet implemented in backend + physics_domain=physics_domain, + node_type=node_type, + lifecycle_filter=lifecycle_filter, ) ) return format_list_report(result) @@ -2731,15 +2736,13 @@ def get_dd_overview( from imas_codex.llm.search_formatters import format_overview_report if include_unit_stats: - logger.debug( - "include_unit_stats not yet implemented in backend, ignoring" - ) + logger.debug("Including unit distribution statistics") tools = _get_imas_tools() result = _run_async( tools.overview_tool.get_dd_overview( query=query, dd_version=dd_version, - # include_unit_stats not yet implemented in backend + include_unit_stats=include_unit_stats, ) ) return format_overview_report(result) @@ -2823,12 +2826,13 @@ def find_related_dd_paths( Returns: Formatted text report with related paths grouped by relationship type, each showing the target path, IDS, and relevance score. """ + from imas_codex.core.paths import normalize_imas_path from imas_codex.llm.search_formatters import format_path_context_report tools = _get_imas_tools() result = _run_async( tools.path_context_tool.get_dd_path_context( - path=path, + path=normalize_imas_path(path), relationship_types=relationship_types, max_results=max_results, dd_version=dd_version, @@ -2858,7 +2862,7 @@ def export_imas_ids( tools = _get_imas_tools() result = _run_async( - tools.structure_tool.export_imas_ids( + tools.structure_tool.export_dd_ids( ids_name=ids_name, leaf_only=leaf_only, dd_version=dd_version, @@ -2886,7 +2890,7 @@ def export_imas_domain( tools = _get_imas_tools() result = _run_async( - tools.structure_tool.export_imas_domain( + tools.structure_tool.export_dd_domain( domain=domain, ids_filter=ids_filter, dd_version=dd_version, @@ -2894,6 +2898,34 @@ def export_imas_domain( ) return format_export_domain_report(result) + @self.mcp.tool() + def get_ids_structure( + ids_name: str, + dd_version: int | None = None, + ) -> str: + """Analyze the internal structure and organization of a specific IMAS IDS. + + Returns a compact overview including: metrics (path counts, depth), top-level sections, + semantic clusters, identifier schemas, COCOS fields, coordinate arrays, and data type distribution. + + Args: + ids_name: IDS name to analyze (e.g. "equilibrium", "core_profiles"). + dd_version: Filter by DD major version (3 or 4). Default: latest version. + + Returns: + Formatted text report with structural overview of the IDS. + """ + from imas_codex.llm.search_formatters import format_ids_structure_report + + tools = _get_imas_tools() + result = _run_async( + tools.structure_tool.get_ids_structure( + ids_name=ids_name, + dd_version=dd_version, + ) + ) + return format_ids_structure_report(result) + @self.mcp.tool() def get_dd_cocos_fields( transformation_type: str | None = None, @@ -2916,7 +2948,7 @@ def get_dd_cocos_fields( tools = _get_imas_tools() result = _run_async( - tools.structure_tool.get_cocos_fields( + tools.structure_tool.get_dd_cocos_fields( transformation_type=transformation_type, ids_filter=ids_filter, dd_version=dd_version, @@ -3372,6 +3404,13 @@ async def health_check(request: Request) -> JSONResponse: "node_count": graph.get("node_count"), "relationship_count": graph.get("relationship_count"), } + # Add GHCR package name for deployment identification + try: + from imas_codex.graph.ghcr import get_package_name + + graph_section["package"] = get_package_name(dd_only=server.dd_only) + except Exception: + pass if server.dd_only: graph_section["variant"] = "dd-only" elif graph.get("facilities"): @@ -3387,9 +3426,9 @@ async def health_check(request: Request) -> JSONResponse: if not server.dd_only: response["facilities"] = graph.get("facilities", []) - # Tool inventory + # Tool inventory — strip FastMCP internal "@" suffix from keys tool_names = sorted( - k.removeprefix("tool:") + k.removeprefix("tool:").rstrip("@") for k in server.mcp._local_provider._components if k.startswith("tool:") ) diff --git a/imas_codex/models/result_models.py b/imas_codex/models/result_models.py index 803d3053a..a98735a16 100644 --- a/imas_codex/models/result_models.py +++ b/imas_codex/models/result_models.py @@ -349,6 +349,14 @@ def tool_name(self) -> str: default=None, description="Unit distribution statistics (top units by path count)", ) + domain_summary: dict[str, Any] | None = Field( + default=None, + description="High-level domain breakdown with IDS counts and path totals", + ) + lifecycle_summary: dict[str, int] | None = Field( + default=None, + description="IDS counts by lifecycle status (active, alpha, etc.)", + ) # ============================================================================ @@ -466,6 +474,10 @@ class CheckPathsResultItem(BaseModel): default=None, description="Suggested correction for not-found paths (typo hints)", ) + suggestions: list[str] | None = Field( + default=None, + description="Multiple fuzzy-match suggestions for not-found paths", + ) error: str | None = Field(default=None, description="Error message if invalid") diff --git a/imas_codex/schemas/facility.yaml b/imas_codex/schemas/facility.yaml index 9077e1280..ec5722cef 100644 --- a/imas_codex/schemas/facility.yaml +++ b/imas_codex/schemas/facility.yaml @@ -181,6 +181,8 @@ enums: imas: description: IMAS data format + # StandardNameSource enum moved to standard_name.yaml + # Note: IngestionStatus, PathStatus, SourceFileStatus, AgentRunStatus, # EnrichmentStatus are defined in common.yaml with unified terminology. # See common.yaml for the design philosophy and value definitions. @@ -2085,29 +2087,7 @@ classes: description: Number of FacilitySignal nodes linked to this diagnostic range: integer - StandardName: - description: >- - A canonical physics or geometric quantity name. Part of the standard_names - vocabulary used to unify signal semantics across facilities. - - Relationships: - - HAS_UNIT -> Unit (canonical units for this quantity) - - IN_DOMAIN -> PhysicsDomainNode (optional grouping) - - Example: plasma_current, electron_density_core, major_radius - class_uri: facility:StandardName - attributes: - id: - identifier: true - description: >- - The standard name in snake_case. - E.g., plasma_current, electron_temperature, major_radius - required: true - description: - description: Human-readable definition of this quantity - canonical_units: - description: Expected SI units for this quantity - range: Unit + # StandardName class moved to standard_name.yaml FacilitySignal: description: >- diff --git a/imas_codex/schemas/imas_dd.yaml b/imas_codex/schemas/imas_dd.yaml index 913a30cf3..f07da1393 100644 --- a/imas_codex/schemas/imas_dd.yaml +++ b/imas_codex/schemas/imas_dd.yaml @@ -34,6 +34,7 @@ default_range: string imports: - linkml:types - common + - standard_name # ============================================================================= # Enums @@ -816,6 +817,13 @@ classes: range: IMASSemanticCluster annotations: relationship_type: IN_CLUSTER + standard_name: + description: >- + Canonical physics quantity name for cross-facility semantic + unification. Links DD paths to StandardName nodes. + range: StandardName + annotations: + relationship_type: HAS_STANDARD_NAME coordinates: description: >- Coordinate specifications for this path (axis definitions). diff --git a/imas_codex/schemas/standard_name.yaml b/imas_codex/schemas/standard_name.yaml new file mode 100644 index 000000000..43511b580 --- /dev/null +++ b/imas_codex/schemas/standard_name.yaml @@ -0,0 +1,141 @@ +# Standard Names Schema +# +# Canonical physics quantity names for cross-facility semantic unification. +# StandardName bridges IMAS Data Dictionary paths and facility signals — +# it is neither DD-specific nor facility-specific, hence its own schema. +# +# Import chain: +# common.yaml (provides Unit) +# ↑ +# standard_name.yaml (this file) +# ↑ ↑ +# imas_dd.yaml facility.yaml +# +# Generated models: imas_codex/graph/models.py, imas_codex/graph/dd_models.py +# To regenerate: uv run build-models --force + +id: https://imas.iter.org/schemas/standard_name +name: standard_name +title: IMAS Standard Names Schema +description: >- + Canonical physics quantity names for cross-facility semantic unification. + Links DD paths (IMASNode) and facility signals (FacilitySignal) to a + shared vocabulary of physics concepts. + +license: MIT +version: 0.4.0 + +prefixes: + linkml: https://w3id.org/linkml/ + sn: https://imas.iter.org/schemas/standard_name/ + +default_prefix: sn +default_range: string + +imports: + - linkml:types + - common + +# ============================================================================= +# Enums +# ============================================================================= + +enums: + StandardNameSource: + description: Source type for standard name generation + permissible_values: + dd: + description: Extracted from IMAS Data Dictionary + signals: + description: Extracted from facility signals + manual: + description: Manually curated + + StandardNameReviewStatus: + description: Review lifecycle for standard names + permissible_values: + candidate: + description: Generated by LLM, awaiting review + accepted: + description: Reviewed and accepted into vocabulary + rejected: + description: Reviewed and rejected + skipped: + description: Skipped during review (e.g., low confidence) + +# ============================================================================= +# Classes +# ============================================================================= + +classes: + StandardName: + description: >- + A canonical physics or geometric quantity name. Part of the standard_names + vocabulary used to unify signal semantics across facilities and DD paths. + + Relationships: + - HAS_UNIT -> Unit (canonical units for this quantity) + - IN_DOMAIN -> PhysicsDomainNode (optional grouping) + + Inbound relationships: + - (IMASNode)-[:HAS_STANDARD_NAME]->(StandardName) + - (FacilitySignal)-[:MEASURES]->(StandardName) + + Example: plasma_current, electron_density_core, major_radius + class_uri: sn:StandardName + attributes: + id: + identifier: true + description: >- + The standard name in snake_case. + E.g., plasma_current, electron_temperature, major_radius + required: true + description: + description: Human-readable definition of this quantity + physical_base: + description: >- + The core physics quantity (e.g., "temperature", "density", + "current"). Used for grouping related standard names. + canonical_units: + description: Expected SI units for this quantity + range: Unit + source: + description: >- + How this standard name was generated. + dd: extracted from IMAS Data Dictionary paths. + signals: extracted from facility signal descriptions. + manual: manually curated entry. + range: StandardNameSource + source_type: + description: >- + Alias for source, used by older generation pipelines. + Prefer 'source' for new code. + range: StandardNameSource + source_path: + description: >- + The source entity ID this name was derived from. + For DD: the IMASNode path (e.g., equilibrium/time_slice/profiles_1d/psi). + For signals: the FacilitySignal ID (e.g., tcv:equilibrium/plasma_current). + confidence: + description: LLM confidence score for generated names (0-1) + range: float + review_status: + description: Review lifecycle status + range: StandardNameReviewStatus + model: + description: >- + LLM model that generated this standard name + (e.g., "google/gemini-3-flash"). Tracked for provenance. + created_at: + description: ISO 8601 timestamp when this name was created + range: datetime + generated_at: + description: ISO 8601 timestamp when LLM generation occurred + range: datetime + embedding: + description: Vector embedding of description for semantic search + multivalued: true + range: float + embedded_at: + description: When the embedding was last computed + range: datetime diff --git a/imas_codex/sn/__init__.py b/imas_codex/sn/__init__.py new file mode 100644 index 000000000..52ccc3237 --- /dev/null +++ b/imas_codex/sn/__init__.py @@ -0,0 +1,9 @@ +"""Standard name generation pipeline. + +Multi-source pipeline that extracts physics quantities from graph entities +(DD paths, facility signals), composes grammatically valid standard names +via LLM, validates them, and publishes to the catalog. + +Pipeline phases: EXTRACT → COMPOSE → VALIDATE +(REVIEW and PUBLISH are future features 06 and 08) +""" diff --git a/imas_codex/sn/benchmark.py b/imas_codex/sn/benchmark.py new file mode 100644 index 000000000..37997c383 --- /dev/null +++ b/imas_codex/sn/benchmark.py @@ -0,0 +1,537 @@ +"""Benchmark runner for comparing LLM models on standard name generation. + +Extracts a fixed dataset, runs it through multiple models, validates +output via grammar round-trip, and compares against a reference set. +Produces a :class:`BenchmarkReport` with per-model metrics suitable +for Rich table display and JSON export. +""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import asdict, dataclass, field +from datetime import UTC, datetime +from typing import Any + +from imas_standard_names.grammar import ( + BinaryOperator, + Component, + GeometricBase, + Object, + Position, + Process, + StandardName, + Subject, + Transformation, + compose_standard_name, + parse_standard_name, +) + +from imas_codex.sn.models import SNComposeBatch + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Configuration and result dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class BenchmarkConfig: + """Configuration for a benchmark run.""" + + models: list[str] + source: str = "dd" + ids_filter: str | None = None + domain_filter: str | None = None + facility: str | None = None + max_candidates: int = 50 + runs_per_model: int = 1 + temperature: float = 0.0 # pinned for reproducibility + + +@dataclass +class ModelResult: + """Results from running one model.""" + + model: str + candidates: list[dict] = field(default_factory=list) + grammar_valid_count: int = 0 + grammar_invalid_count: int = 0 + fields_consistent_count: int = 0 + total_cost: float = 0.0 + total_tokens: int = 0 + elapsed_seconds: float = 0.0 + names_per_minute: float = 0.0 + cost_per_name: float = 0.0 + skipped_count: int = 0 + batch_errors: int = 0 + # Quality against reference set + reference_overlap: int = 0 + reference_total: int = 0 + reference_precision: float = 0.0 + reference_recall: float = 0.0 + + +@dataclass +class BenchmarkReport: + """Full benchmark report.""" + + config: BenchmarkConfig + results: list[ModelResult] + reference_names: list[str] + extraction_count: int = 0 + timestamp: str = "" + + def to_json(self) -> str: + """Serialize to JSON string.""" + return json.dumps(asdict(self), indent=2, default=str) + + @classmethod + def from_json(cls, data: str) -> BenchmarkReport: + """Deserialize from JSON string.""" + raw = json.loads(data) + config = BenchmarkConfig(**raw["config"]) + results = [ModelResult(**r) for r in raw["results"]] + return cls( + config=config, + results=results, + reference_names=raw["reference_names"], + extraction_count=raw.get("extraction_count", 0), + timestamp=raw.get("timestamp", ""), + ) + + +# --------------------------------------------------------------------------- +# Grammar context builder +# --------------------------------------------------------------------------- + + +def build_grammar_context() -> dict[str, list[str]]: + """Build the grammar enum values needed by the compose prompt. + + Returns a dict with keys matching the template variables in + ``sn/compose_dd.md``: subjects, positions, components, coordinates, + processes, transformations, geometric_bases, objects, binary_operators. + """ + return { + "subjects": [e.value for e in Subject], + "positions": [e.value for e in Position], + "components": [e.value for e in Component], + "coordinates": [e.value for e in Component], # same enum + "processes": [e.value for e in Process], + "transformations": [e.value for e in Transformation], + "geometric_bases": [e.value for e in GeometricBase], + "objects": [e.value for e in Object], + "binary_operators": [e.value for e in BinaryOperator], + } + + +# --------------------------------------------------------------------------- +# Grammar validation +# --------------------------------------------------------------------------- + + +def validate_candidate(candidate: dict) -> tuple[bool, bool]: + """Validate a single candidate via grammar round-trip. + + Returns: + (grammar_valid, fields_consistent) tuple. + grammar_valid: True if the name parses and round-trips. + fields_consistent: True if composing from reported fields + produces the same name (after normalization). + """ + name = candidate.get("standard_name", "") + fields = candidate.get("fields", {}) + + grammar_valid = False + fields_consistent = False + + # Check grammar round-trip + try: + parsed = parse_standard_name(name) + normalized = compose_standard_name(parsed) + grammar_valid = True # parse+compose succeeded + except Exception: + return False, False + + # Check fields consistency: compose from reported fields + try: + # Convert string field values to enum instances + sn_fields: dict[str, Any] = {} + for k, v in fields.items(): + if k == "physical_base": + sn_fields[k] = v + elif k == "geometric_base": + sn_fields[k] = GeometricBase(v) + elif k == "subject": + sn_fields[k] = Subject(v) + elif k == "component": + sn_fields[k] = Component(v) + elif k == "coordinate": + sn_fields[k] = Component(v) + elif k == "position": + sn_fields[k] = Position(v) + elif k == "process": + sn_fields[k] = Process(v) + elif k == "transformation": + sn_fields[k] = Transformation(v) + elif k == "object": + sn_fields[k] = Object(v) + elif k == "binary_operator": + sn_fields[k] = BinaryOperator(v) + + if sn_fields: + sn = StandardName(**sn_fields) + from_fields = compose_standard_name(sn) + fields_consistent = from_fields == normalized + except Exception: + pass + + return grammar_valid, fields_consistent + + +# --------------------------------------------------------------------------- +# Reference comparison +# --------------------------------------------------------------------------- + + +def compare_to_reference( + candidates: list[dict], + reference: dict[str, dict], +) -> tuple[int, int, float, float]: + """Compare model output against the reference set. + + Args: + candidates: List of candidate dicts with source_id and standard_name. + reference: REFERENCE_NAMES dict mapping source_path → {name, fields}. + + Returns: + (overlap, ref_total, precision, recall) tuple. + overlap: Number of candidates whose standard_name matches reference. + ref_total: Total entries in reference set. + precision: overlap / len(candidates) if candidates else 0. + recall: overlap / ref_total if ref_total else 0. + """ + # Build lookup from source_id → generated name + generated = {} + for c in candidates: + sid = c.get("source_id", "") + generated[sid] = c.get("standard_name", "") + + overlap = 0 + ref_total = len(reference) + for path, ref_entry in reference.items(): + if path in generated: + # Normalize both for comparison + gen_name = generated[path] + ref_name = ref_entry["name"] + try: + gen_parsed = parse_standard_name(gen_name) + gen_normalized = compose_standard_name(gen_parsed) + except Exception: + gen_normalized = gen_name + + try: + ref_parsed = parse_standard_name(ref_name) + ref_normalized = compose_standard_name(ref_parsed) + except Exception: + ref_normalized = ref_name + + if gen_normalized == ref_normalized: + overlap += 1 + + n_candidates = len(candidates) + precision = overlap / n_candidates if n_candidates else 0.0 + recall = overlap / ref_total if ref_total else 0.0 + + return overlap, ref_total, precision, recall + + +# --------------------------------------------------------------------------- +# Core benchmark runner +# --------------------------------------------------------------------------- + + +async def run_benchmark( + config: BenchmarkConfig, + extraction_batches: list[dict] | None = None, +) -> BenchmarkReport: + """Run the benchmark across all configured models. + + Args: + config: Benchmark configuration. + extraction_batches: Pre-extracted candidate batches (list of dicts + with items grouped by IDS). If None, extracts from graph. + + Returns: + BenchmarkReport with per-model results. + """ + from imas_codex.sn.benchmark_reference import REFERENCE_NAMES + + # --- 1. Extract candidates (same for all models) --- + if extraction_batches is None: + extraction_batches = _extract_candidates(config) + + # Flatten items for counting + all_items = [] + for batch in extraction_batches: + all_items.extend(batch.get("items", [])) + + # Limit to max_candidates + if len(all_items) > config.max_candidates: + all_items = all_items[: config.max_candidates] + # Rebuild batches with limited items + extraction_batches = _rebuild_batches(extraction_batches, config.max_candidates) + + logger.info( + "Benchmark: %d extraction items across %d batches", + len(all_items), + len(extraction_batches), + ) + + # --- 2. Run each model --- + results: list[ModelResult] = [] + for model in config.models: + logger.info("Benchmarking model: %s", model) + model_result = await _run_model( + model=model, + extraction_batches=extraction_batches, + config=config, + reference=REFERENCE_NAMES, + ) + results.append(model_result) + + # --- 3. Build report --- + report = BenchmarkReport( + config=config, + results=results, + reference_names=list(REFERENCE_NAMES.keys()), + extraction_count=len(all_items), + timestamp=datetime.now(tz=UTC).isoformat(), + ) + return report + + +def _extract_candidates(config: BenchmarkConfig) -> list[dict]: + """Extract candidates from the graph DB. + + Returns list of batch dicts with keys: group_key, items, existing_names. + """ + from imas_codex.sn.sources.dd import extract_dd_candidates + + batches = extract_dd_candidates( + ids_filter=config.ids_filter, + domain_filter=config.domain_filter, + limit=config.max_candidates, + ) + + # Convert ExtractionBatch to plain dicts + result = [] + for batch in batches: + result.append( + { + "group_key": batch.group_key, + "items": batch.items, + "existing_names": list(batch.existing_names), + } + ) + return result + + +def _rebuild_batches(batches: list[dict], max_items: int) -> list[dict]: + """Rebuild batches capping total items at max_items.""" + result = [] + count = 0 + for batch in batches: + items = batch.get("items", []) + remaining = max_items - count + if remaining <= 0: + break + if len(items) > remaining: + items = items[:remaining] + result.append({**batch, "items": items}) + count += len(items) + return result + + +async def _run_model( + model: str, + extraction_batches: list[dict], + config: BenchmarkConfig, + reference: dict[str, dict], +) -> ModelResult: + """Run a single model across all extraction batches.""" + from imas_codex.discovery.base.llm import acall_llm_structured + from imas_codex.llm.prompt_loader import render_prompt + + grammar_ctx = build_grammar_context() + result = ModelResult(model=model) + all_candidates: list[dict] = [] + + t0 = time.monotonic() + + for _run_idx in range(config.runs_per_model): + for batch in extraction_batches: + items = batch.get("items", []) + if not items: + continue + + group_key = batch.get("group_key", "unknown") + existing = set(batch.get("existing_names", [])) + + # Build prompt context + prompt_context = { + "items": items, + "ids_name": group_key, + "existing_names": list(existing), + **grammar_ctx, + } + + try: + prompt_text = render_prompt("sn/compose_dd", prompt_context) + except Exception: + logger.warning("Failed to render prompt for batch %s", group_key) + result.batch_errors += 1 + continue + + messages = [{"role": "user", "content": prompt_text}] + + try: + llm_result, cost, tokens = await acall_llm_structured( + model=model, + messages=messages, + response_model=SNComposeBatch, + temperature=config.temperature, + ) + result.total_cost += cost + result.total_tokens += tokens + + # Collect candidates + for c in llm_result.candidates: + all_candidates.append(c.model_dump()) + result.skipped_count += len(llm_result.skipped) + + except Exception as exc: + logger.warning( + "LLM call failed for model %s batch %s: %s", + model, + group_key, + exc, + ) + result.batch_errors += 1 + + elapsed = time.monotonic() - t0 + result.elapsed_seconds = round(elapsed, 2) + result.candidates = all_candidates + + # --- Validate grammar --- + valid = 0 + invalid = 0 + fields_ok = 0 + for c in all_candidates: + g_valid, f_consistent = validate_candidate(c) + if g_valid: + valid += 1 + else: + invalid += 1 + if f_consistent: + fields_ok += 1 + + result.grammar_valid_count = valid + result.grammar_invalid_count = invalid + result.fields_consistent_count = fields_ok + + # --- Derived metrics --- + n = len(all_candidates) + if elapsed > 0 and n > 0: + result.names_per_minute = round(n / elapsed * 60, 1) + if n > 0: + result.cost_per_name = round(result.total_cost / n, 6) + + # --- Reference comparison --- + overlap, ref_total, precision, recall = compare_to_reference( + all_candidates, reference + ) + result.reference_overlap = overlap + result.reference_total = ref_total + result.reference_precision = round(precision, 4) + result.reference_recall = round(recall, 4) + + logger.info( + "Model %s: %d names, %d valid, %d invalid, $%.4f cost, %.1f names/min", + model, + n, + valid, + invalid, + result.total_cost, + result.names_per_minute, + ) + + return result + + +# --------------------------------------------------------------------------- +# Rich table rendering +# --------------------------------------------------------------------------- + + +def render_comparison_table(report: BenchmarkReport) -> None: + """Render a Rich comparison table to stdout.""" + from rich.console import Console + from rich.table import Table + + console = Console() + + table = Table( + title="SN Benchmark Results", + show_header=True, + header_style="bold cyan", + ) + + table.add_column("Model", style="bold") + table.add_column("Names", justify="right") + table.add_column("Valid %", justify="right") + table.add_column("Fields %", justify="right") + table.add_column("Ref Match", justify="right") + table.add_column("Cost", justify="right") + table.add_column("Names/min", justify="right") + table.add_column("$/name", justify="right") + table.add_column("Errors", justify="right") + + for r in report.results: + n = len(r.candidates) + valid_pct = f"{r.grammar_valid_count / n * 100:.0f}%" if n else "—" + fields_pct = f"{r.fields_consistent_count / n * 100:.0f}%" if n else "—" + ref_match = ( + f"{r.reference_overlap}/{r.reference_total}" if r.reference_total else "—" + ) + cost_str = f"${r.total_cost:.4f}" if r.total_cost > 0 else "—" + speed_str = f"{r.names_per_minute:.0f}" if r.names_per_minute > 0 else "—" + cpn_str = f"${r.cost_per_name:.4f}" if r.cost_per_name > 0 else "—" + err_str = str(r.batch_errors) if r.batch_errors > 0 else "0" + + table.add_row( + r.model, + str(n), + valid_pct, + fields_pct, + ref_match, + cost_str, + speed_str, + cpn_str, + err_str, + ) + + console.print() + console.print(table) + + # Summary line + console.print( + f"\n[dim]Extraction: {report.extraction_count} items | " + f"Temperature: {report.config.temperature} | " + f"Timestamp: {report.timestamp}[/dim]" + ) diff --git a/imas_codex/sn/benchmark_reference.py b/imas_codex/sn/benchmark_reference.py new file mode 100644 index 000000000..828eefccb --- /dev/null +++ b/imas_codex/sn/benchmark_reference.py @@ -0,0 +1,162 @@ +"""Reference dataset of known-good standard names for benchmarking. + +Each entry maps a DD source path to its expected standard name and +the grammar fields used to compose it. Every name in this set must +pass a round-trip through ``parse_standard_name`` → ``compose_standard_name``. + +The dataset covers a representative range of grammar features: +simple physical bases, subject-qualified quantities, component-qualified +vector quantities, positional variants, compound physical bases, and +geometric quantities. +""" + +from __future__ import annotations + +from imas_standard_names.grammar import ( + Component, + GeometricBase, + Object, + Position, + StandardName, + Subject, + compose_standard_name, +) + +# --------------------------------------------------------------------------- +# Helper: build a reference entry from grammar fields +# --------------------------------------------------------------------------- + + +def _ref(fields: dict) -> dict: + """Build a reference entry dict with name string and fields. + + Composes the standard name from the fields at import time so any + grammar error is caught immediately. + """ + sn = StandardName(**fields) + name = compose_standard_name(sn) + # Store string-valued fields for JSON serialization + str_fields = {} + for k, v in fields.items(): + if hasattr(v, "value"): + str_fields[k] = v.value + else: + str_fields[k] = v + return {"name": name, "fields": str_fields} + + +# --------------------------------------------------------------------------- +# Reference dataset +# --------------------------------------------------------------------------- + +REFERENCE_NAMES: dict[str, dict] = { + # --- Simple physical bases --- + "equilibrium/time_slice/profiles_1d/safety_factor": _ref( + {"physical_base": "safety_factor"} + ), + "equilibrium/time_slice/global_quantities/magnetic_axis/b_field_tor": _ref( + {"physical_base": "magnetic_field", "component": Component.TOROIDAL} + ), + "equilibrium/time_slice/profiles_1d/elongation": _ref( + {"physical_base": "elongation"} + ), + "equilibrium/time_slice/profiles_1d/triangularity_upper": _ref( + {"physical_base": "triangularity"} + ), + "equilibrium/time_slice/profiles_1d/magnetic_shear": _ref( + {"physical_base": "magnetic_shear"} + ), + "equilibrium/time_slice/global_quantities/beta_pol": _ref( + {"physical_base": "beta"} + ), + # --- Subject-qualified quantities --- + "core_profiles/profiles_1d/electrons/temperature": _ref( + {"physical_base": "temperature", "subject": Subject.ELECTRON} + ), + "core_profiles/profiles_1d/ion/temperature": _ref( + {"physical_base": "temperature", "subject": Subject.ION} + ), + "core_profiles/profiles_1d/electrons/density": _ref( + {"physical_base": "density", "subject": Subject.ELECTRON} + ), + "core_profiles/profiles_1d/ion/density": _ref( + {"physical_base": "density", "subject": Subject.ION} + ), + "core_profiles/profiles_1d/electrons/pressure": _ref( + {"physical_base": "pressure", "subject": Subject.ELECTRON} + ), + "core_profiles/profiles_1d/ion/pressure": _ref( + {"physical_base": "pressure", "subject": Subject.ION} + ), + # --- Component-qualified vector quantities --- + "equilibrium/time_slice/profiles_1d/j_tor": _ref( + {"physical_base": "current_density", "component": Component.TOROIDAL} + ), + "equilibrium/time_slice/profiles_1d/j_parallel": _ref( + {"physical_base": "current_density", "component": Component.PARALLEL} + ), + "magnetics/b_field_pol_probe/field/data": _ref( + {"physical_base": "magnetic_field", "component": Component.POLOIDAL} + ), + "magnetics/b_field_tor_probe/field/data": _ref( + {"physical_base": "magnetic_field", "component": Component.TOROIDAL} + ), + "core_profiles/profiles_1d/rotation_frequency_tor_sonic": _ref( + {"physical_base": "rotation_frequency", "component": Component.TOROIDAL} + ), + # --- Position-qualified quantities --- + "core_profiles/profiles_1d/electrons/temperature_fit/boundary_condition/value": _ref( + { + "physical_base": "temperature", + "subject": Subject.ELECTRON, + "position": Position.PLASMA_BOUNDARY, + } + ), + "equilibrium/time_slice/global_quantities/magnetic_axis/r": _ref( + { + "geometric_base": GeometricBase.POSITION, + "object": Object.ROGOWSKI_COIL, + "position": Position.MAGNETIC_AXIS, + } + ), + # --- Compound physical bases (generic terms qualified via compounding) --- + "equilibrium/time_slice/global_quantities/ip": _ref( + {"physical_base": "plasma_current"} + ), + "equilibrium/time_slice/global_quantities/psi_axis": _ref( + {"physical_base": "poloidal_magnetic_flux"} + ), + "equilibrium/time_slice/global_quantities/psi_boundary": _ref( + { + "physical_base": "poloidal_magnetic_flux", + "position": Position.PLASMA_BOUNDARY, + } + ), + "equilibrium/time_slice/global_quantities/energy_mhd": _ref( + {"physical_base": "stored_energy"} + ), + "summary/global_quantities/v_loop/value": _ref({"physical_base": "loop_voltage"}), + "summary/global_quantities/li/value": _ref( + {"physical_base": "internal_inductance"} + ), + "equilibrium/time_slice/global_quantities/resistivity": _ref( + {"physical_base": "resistivity"} + ), + "equilibrium/time_slice/global_quantities/magnetic_axis/b_tor": _ref( + {"physical_base": "toroidal_magnetic_field"} + ), + # --- Geometric bases --- + "equilibrium/time_slice/global_quantities/minor_radius": _ref( + {"physical_base": "minor_radius"} + ), + "equilibrium/time_slice/global_quantities/major_radius": _ref( + {"physical_base": "major_radius"} + ), + "equilibrium/time_slice/global_quantities/aspect_ratio": _ref( + {"physical_base": "aspect_ratio"} + ), +} +"""Map of DD source_path → {name: str, fields: dict}. + +Each entry is a known-good standard name that passes grammar round-trip. +""" diff --git a/imas_codex/sn/context.py b/imas_codex/sn/context.py new file mode 100644 index 000000000..b4087a408 --- /dev/null +++ b/imas_codex/sn/context.py @@ -0,0 +1,270 @@ +"""Rich grammar context for SN compose prompts. + +Imports segment rules, vocabulary, field guidance, and curated examples +from imas_standard_names backing functions. Assembles them into template +variables for Jinja2 rendering. + +Caches assembled context in-process (module-level dict). +""" + +from __future__ import annotations + +import logging +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Cached context builder +# --------------------------------------------------------------------------- + +_CONTEXT_CACHE: dict[str, Any] | None = None + + +def build_compose_context() -> dict[str, Any]: + """Build rich context dict for sn/compose_system.md template. + + Returns keys needed by both system and user prompts: + - grammar_rules: canonical pattern, order constraint, template rules + - vocabulary: per-segment token lists with descriptions + - segment_descriptions: detailed segment usage guidance + - field_guidance: per-field content rules and validation + - examples: curated standard name examples (YAML) + - tokamak_ranges: machine parameter data for grounding + - exclusive_pairs: mutually exclusive segment pairs + - enum lists: subjects, positions, etc. (for user prompt backward compat) + """ + global _CONTEXT_CACHE + if _CONTEXT_CACHE is not None: + return _CONTEXT_CACHE + + ctx: dict[str, Any] = {} + + # Grammar rules + ctx["canonical_pattern"] = _get_canonical_pattern() + ctx["segment_order"] = _get_segment_order() + ctx["template_rules"] = _get_template_rules() + ctx["exclusive_pairs"] = _get_exclusive_pairs() + + # Vocabulary with descriptions + ctx["vocabulary_sections"] = _build_vocabulary_sections() + + # Segment descriptions and usage guidance + ctx["segment_descriptions"] = _get_all_segment_descriptions() + + # Field guidance for documentation generation + ctx["field_guidance"] = _get_field_guidance() + + # Curated examples + ctx["examples"] = _load_curated_examples() + + # Tokamak parameter ranges for documentation grounding + ctx["tokamak_ranges"] = _load_tokamak_ranges() + + # Bare enum lists (backward compat for user prompt) + ctx.update(_build_enum_lists()) + + _CONTEXT_CACHE = ctx + return ctx + + +def clear_context_cache() -> None: + """Clear cached context (for testing).""" + global _CONTEXT_CACHE + _CONTEXT_CACHE = None + + +# --------------------------------------------------------------------------- +# Grammar rules +# --------------------------------------------------------------------------- + + +@lru_cache(maxsize=1) +def _get_canonical_pattern() -> str: + from imas_standard_names.tools.grammar import _build_canonical_pattern + + return _build_canonical_pattern() + + +@lru_cache(maxsize=1) +def _get_segment_order() -> str: + from imas_standard_names.tools.grammar import _build_segment_order_constraint + + return _build_segment_order_constraint() + + +@lru_cache(maxsize=1) +def _get_template_rules() -> str: + from imas_standard_names.tools.grammar import _build_template_application_rule + + return _build_template_application_rule() + + +@lru_cache(maxsize=1) +def _get_exclusive_pairs() -> list[tuple[str, str]]: + from imas_standard_names.grammar.constants import EXCLUSIVE_SEGMENT_PAIRS + + return list(EXCLUSIVE_SEGMENT_PAIRS) + + +# --------------------------------------------------------------------------- +# Vocabulary +# --------------------------------------------------------------------------- + + +def _build_vocabulary_sections() -> list[dict[str, Any]]: + """Build per-segment vocabulary sections with tokens and descriptions.""" + from imas_standard_names.grammar.constants import SEGMENT_RULES + from imas_standard_names.tools.grammar import _get_vocabulary_description + + sections = [] + for rule in SEGMENT_RULES: + seg_id = rule.identifier + desc = _get_vocabulary_description(seg_id) + tokens = list(rule.tokens) if rule.tokens else [] + template = rule.template + + sections.append( + { + "segment": seg_id, + "description": desc, + "tokens": tokens, + "template": template, + "is_open": seg_id == "physical_base", + "exclusive_with": list(rule.exclusive_with) + if rule.exclusive_with + else [], + } + ) + return sections + + +# --------------------------------------------------------------------------- +# Segment descriptions +# --------------------------------------------------------------------------- + + +@lru_cache(maxsize=1) +def _get_all_segment_descriptions() -> dict[str, str]: + from imas_standard_names.tools.grammar import _get_segment_descriptions + + return _get_segment_descriptions() + + +# --------------------------------------------------------------------------- +# Field guidance +# --------------------------------------------------------------------------- + + +@lru_cache(maxsize=1) +def _get_field_guidance() -> dict[str, Any]: + from imas_standard_names.grammar.field_schemas import ( + FIELD_GUIDANCE, + TYPE_SPECIFIC_REQUIREMENTS, + ) + + return { + "fields": dict(FIELD_GUIDANCE), + "type_requirements": dict(TYPE_SPECIFIC_REQUIREMENTS), + } + + +# --------------------------------------------------------------------------- +# Curated examples +# --------------------------------------------------------------------------- + + +@lru_cache(maxsize=1) +def _load_curated_examples() -> list[dict[str, Any]]: + """Load all curated standard name examples from imas_standard_names resources.""" + import imas_standard_names + + pkg_path = Path(imas_standard_names.__path__[0]) + examples_dir = pkg_path / "resources" / "standard_name_examples" + + if not examples_dir.exists(): + logger.warning("No curated examples directory at %s", examples_dir) + return [] + + examples = [] + for yml_path in sorted(examples_dir.rglob("*.yml")): + try: + with open(yml_path) as f: + data = yaml.safe_load(f) + if data and isinstance(data, dict) and "name" in data: + # Add the category from directory name + data["category"] = yml_path.parent.name + examples.append(data) + except Exception: + logger.debug("Failed to load example: %s", yml_path) + + logger.info("Loaded %d curated standard name examples", len(examples)) + return examples + + +# --------------------------------------------------------------------------- +# Tokamak parameters +# --------------------------------------------------------------------------- + + +@lru_cache(maxsize=1) +def _load_tokamak_ranges() -> dict[str, dict[str, Any]]: + """Load tokamak machine parameters for documentation grounding.""" + import imas_standard_names + + pkg_path = Path(imas_standard_names.__path__[0]) + params_dir = pkg_path / "resources" / "tokamak_parameters" + + if not params_dir.exists(): + logger.warning("No tokamak parameters directory at %s", params_dir) + return {} + + machines: dict[str, dict[str, Any]] = {} + for yml_path in sorted(params_dir.glob("*.yml")): + if yml_path.name in ("schema.yml", "README.md"): + continue + try: + with open(yml_path) as f: + data = yaml.safe_load(f) + if data and isinstance(data, dict) and "machine" in data: + machines[data["machine"]] = data + except Exception: + logger.debug("Failed to load tokamak params: %s", yml_path) + + logger.info("Loaded %d tokamak parameter sets", len(machines)) + return machines + + +# --------------------------------------------------------------------------- +# Backward-compatible enum lists +# --------------------------------------------------------------------------- + + +def _build_enum_lists() -> dict[str, list[str]]: + """Build bare enum lists for user prompt template variables.""" + from imas_standard_names.grammar import ( + BinaryOperator, + Component, + GeometricBase, + Object, + Position, + Process, + Subject, + Transformation, + ) + + return { + "subjects": [e.value for e in Subject], + "positions": [e.value for e in Position], + "components": [e.value for e in Component], + "coordinates": [e.value for e in Component], # same enum + "processes": [e.value for e in Process], + "transformations": [e.value for e in Transformation], + "geometric_bases": [e.value for e in GeometricBase], + "objects": [e.value for e in Object], + "binary_operators": [e.value for e in BinaryOperator], + } diff --git a/imas_codex/sn/graph_ops.py b/imas_codex/sn/graph_ops.py new file mode 100644 index 000000000..6c74b63e2 --- /dev/null +++ b/imas_codex/sn/graph_ops.py @@ -0,0 +1,317 @@ +"""Graph operations for the standard-name pipeline. + +Provides read/write helpers that query or mutate StandardName nodes and +their HAS_STANDARD_NAME relationships in the Neo4j knowledge graph. + +Relationship direction: entity → concept + (:IMASNode)-[:HAS_STANDARD_NAME]->(sn:StandardName) + (:FacilitySignal)-[:HAS_STANDARD_NAME]->(sn:StandardName) +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Read helpers — extraction candidates +# ============================================================================= + + +def get_extraction_candidates_dd( + ids_filter: str | None = None, + domain_filter: str | None = None, + limit: int = 500, +) -> list[dict[str, Any]]: + """Query IMASNode paths grouped by semantic cluster. + + Returns dynamic leaf nodes that have been enriched (status=embedded), + optionally filtered by IDS or physics domain. + """ + from imas_codex.graph.client import GraphClient + + with GraphClient() as gc: + params: dict[str, Any] = {"limit": limit} + where_clauses = [ + "n.node_type = 'dynamic'", + "n.description IS NOT NULL", + "n.description <> ''", + ] + + if ids_filter: + where_clauses.append("ids.id = $ids_filter") + params["ids_filter"] = ids_filter + if domain_filter: + where_clauses.append("n.physics_domain = $domain_filter") + params["domain_filter"] = domain_filter + + where = " AND ".join(where_clauses) + results = gc.query( + f""" + MATCH (n:IMASNode)-[:IN_IDS]->(ids:IDS) + WHERE {where} + WITH n, ids + OPTIONAL MATCH (n)-[:IN_CLUSTER]->(c:IMASSemanticCluster) + RETURN n.id AS path, n.description AS description, + n.units AS units, n.data_type AS data_type, + ids.id AS ids_name, c.label AS cluster_label + ORDER BY ids.id, n.id + LIMIT $limit + """, + **params, + ) + return list(results) + + +def get_extraction_candidates_signals( + facility: str, + domain_filter: str | None = None, + limit: int = 500, +) -> list[dict[str, Any]]: + """Query FacilitySignal nodes for a given facility. + + Returns signals that have been enriched, optionally filtered by + physics domain. + """ + from imas_codex.graph.client import GraphClient + + with GraphClient() as gc: + params: dict[str, Any] = {"facility": facility, "limit": limit} + where_clauses = ["s.status = 'enriched'"] + + if domain_filter: + where_clauses.append("s.physics_domain = $domain_filter") + params["domain_filter"] = domain_filter + + where = " AND ".join(where_clauses) + results = gc.query( + f""" + MATCH (s:FacilitySignal)-[:AT_FACILITY]->(f:Facility {{id: $facility}}) + WHERE {where} + WITH s + OPTIONAL MATCH (s)-[:MAPS_TO]->(m:IMASNode) + RETURN s.id AS signal_id, s.description AS description, + s.physics_domain AS physics_domain, + s.units AS units, + m.id AS imas_path + ORDER BY s.id + LIMIT $limit + """, + **params, + ) + return list(results) + + +# ============================================================================= +# Deduplication +# ============================================================================= + + +def get_existing_standard_names() -> set[str]: + """Return the set of existing StandardName node IDs for deduplication.""" + from imas_codex.graph.client import GraphClient + + with GraphClient() as gc: + results = gc.query("MATCH (sn:StandardName) RETURN sn.id AS id") + return {r["id"] for r in results} + + +def get_named_source_ids() -> set[str]: + """Return source IDs already linked via HAS_STANDARD_NAME. + + Used for resumability: extract skips sources that already have + a standard name unless --force is specified. + """ + from imas_codex.graph.client import GraphClient + + with GraphClient() as gc: + results = gc.query(""" + MATCH (src)-[:HAS_STANDARD_NAME]->(sn:StandardName) + RETURN DISTINCT src.id AS source_id + """) + return {r["source_id"] for r in results} + + +# ============================================================================= +# Write helpers +# ============================================================================= + + +def write_standard_names(names: list[dict[str, Any]]) -> int: + """MERGE StandardName nodes with HAS_STANDARD_NAME relationships. + + Relationship direction: entity → concept + (:IMASNode)-[:HAS_STANDARD_NAME]->(sn:StandardName) + (:FacilitySignal)-[:HAS_STANDARD_NAME]->(sn:StandardName) + + Each dict in *names* must have at least: + - ``id``: the composed standard name string + - ``source_type``: "dd" or "signal" + - ``source_id``: the originating path / signal ID + + Optional fields: ``physical_base``, ``subject``, ``component``, + ``coordinate``, ``position``, ``units``, ``description``, + ``model``, ``review_status``, ``generated_at``, ``confidence``. + + Returns the number of nodes written. + """ + from imas_codex.graph.client import GraphClient + + if not names: + return 0 + + with GraphClient() as gc: + # MERGE StandardName nodes with provenance + gc.query( + """ + UNWIND $batch AS b + MERGE (sn:StandardName {id: b.id}) + SET sn.source_type = b.source_type, + sn.physical_base = b.physical_base, + sn.subject = b.subject, + sn.component = b.component, + sn.coordinate = b.coordinate, + sn.position = b.position, + sn.units = b.units, + sn.description = b.description, + sn.model = b.model, + sn.review_status = b.review_status, + sn.generated_at = b.generated_at, + sn.confidence = b.confidence, + sn.created_at = coalesce(sn.created_at, datetime()) + """, + batch=[ + { + "id": n["id"], + "source_type": n.get("source_type", ""), + "physical_base": n.get("physical_base"), + "subject": n.get("subject"), + "component": n.get("component"), + "coordinate": n.get("coordinate"), + "position": n.get("position"), + "units": n.get("units"), + "description": n.get("description"), + "model": n.get("model"), + "review_status": n.get("review_status"), + "generated_at": n.get("generated_at"), + "confidence": n.get("confidence"), + } + for n in names + ], + ) + + # Create HAS_STANDARD_NAME relationships: entity → concept + dd_names = [n for n in names if n.get("source_type") == "dd"] + signal_names = [n for n in names if n.get("source_type") == "signal"] + + if dd_names: + gc.query( + """ + UNWIND $batch AS b + MATCH (sn:StandardName {id: b.id}) + MATCH (src:IMASNode {id: b.source_id}) + MERGE (src)-[:HAS_STANDARD_NAME]->(sn) + """, + batch=[ + {"id": n["id"], "source_id": n["source_id"]} + for n in dd_names + if n.get("source_id") + ], + ) + if signal_names: + gc.query( + """ + UNWIND $batch AS b + MATCH (sn:StandardName {id: b.id}) + MATCH (src:FacilitySignal {id: b.source_id}) + MERGE (src)-[:HAS_STANDARD_NAME]->(sn) + """, + batch=[ + {"id": n["id"], "source_id": n["source_id"]} + for n in signal_names + if n.get("source_id") + ], + ) + + written = len(names) + logger.info("Wrote %d StandardName nodes", written) + return written + + +# ============================================================================= +# Read helpers — publish (validated standard names) +# ============================================================================= + + +def get_validated_standard_names( + ids_filter: str | None = None, + confidence_min: float = 0.0, +) -> list[dict[str, Any]]: + """Read validated StandardName nodes and their provenance. + + Queries all StandardName nodes, joining through ``HAS_STANDARD_NAME`` + to find source entities and their parent IDS. Uses ``collect()`` + to avoid row duplication when a name has multiple sources (takes + the first source). + + Parameters + ---------- + ids_filter: + Restrict to names derived from a specific IDS (matched via + ``IMASNode -[:HAS_STANDARD_NAME]-> StandardName`` and + ``IMASNode -[:IN_IDS]-> IDS``). + confidence_min: + Minimum confidence threshold. Nodes without a ``confidence`` + property are treated as 1.0 (grammar-validated). + + Returns + ------- + list of dicts with keys: name, description, source, source_path, + canonical_units, confidence, ids_name. + """ + from imas_codex.graph.client import GraphClient + + with GraphClient() as gc: + params: dict[str, Any] = {"confidence_min": confidence_min} + + # Collect source info — use HAS_STANDARD_NAME (entity → concept) + cypher = """ + MATCH (sn:StandardName) + WHERE coalesce(sn.confidence, 1.0) >= $confidence_min + OPTIONAL MATCH (src)-[:HAS_STANDARD_NAME]->(sn) + OPTIONAL MATCH (src)-[:IN_IDS]->(ids:IDS) + WITH sn, + collect(DISTINCT src.id)[0] AS first_source, + collect(DISTINCT ids.id)[0] AS first_ids + """ + + if ids_filter: + # Re-check: at least one HAS_STANDARD_NAME source must be in the target IDS + cypher += """ + WITH sn, first_source, first_ids + WHERE first_ids = $ids_filter + """ + params["ids_filter"] = ids_filter + + cypher += """ + RETURN sn.id AS name, + sn.description AS description, + coalesce(sn.source, sn.source_type) AS source, + coalesce(sn.source_path, first_source) AS source_path, + coalesce(sn.canonical_units, sn.units) AS canonical_units, + coalesce(sn.confidence, 1.0) AS confidence, + first_ids AS ids_name + ORDER BY sn.id + """ + + results = gc.query(cypher, **params) + logger.info( + "Read %d validated standard names (ids_filter=%s, confidence_min=%.2f)", + len(results), + ids_filter, + confidence_min, + ) + return list(results) diff --git a/imas_codex/sn/models.py b/imas_codex/sn/models.py new file mode 100644 index 000000000..412a64678 --- /dev/null +++ b/imas_codex/sn/models.py @@ -0,0 +1,101 @@ +"""Pydantic models for standard name pipeline LLM responses.""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class SNCandidate(BaseModel): + """A single standard name candidate from LLM composition.""" + + source_id: str = Field(description="Source entity ID (DD path or signal ID)") + standard_name: str = Field(description="Composed standard name") + fields: dict[str, str] = Field(description="Grammar fields used") + confidence: float = Field(ge=0, le=1, description="Naming confidence") + reason: str = Field(description="Brief justification") + + +class SNComposeBatch(BaseModel): + """LLM response for a batch of standard name compositions.""" + + candidates: list[SNCandidate] + skipped: list[str] = Field( + default_factory=list, description="Source IDs skipped (not physics quantities)" + ) + + +# ============================================================================= +# Publish models — YAML catalog export (Feature 08) +# ============================================================================= + + +class SNProvenance(BaseModel): + """Provenance metadata for a standard name entry.""" + + source: str = Field(description="Source type: dd or signal") + source_id: str = Field(description="Source entity ID") + ids_name: str | None = Field(default=None, description="IDS name (for DD source)") + confidence: float = Field(ge=0, le=1, description="Generation confidence") + generated_by: str = Field( + default="imas-codex", description="Tool that generated this" + ) + + +class SNPublishEntry(BaseModel): + """A single standard name entry ready for YAML catalog export.""" + + name: str = Field(description="The standard name") + kind: str = Field( + default="physical", description="Name kind: physical or geometric" + ) + unit: str | None = Field(default=None, description="SI unit string") + tags: list[str] = Field(default_factory=list, description="Classification tags") + status: str = Field(default="candidate", description="Entry status") + description: str = Field(default="", description="Human-readable description") + provenance: SNProvenance = Field(description="Generation provenance") + + +class SNPublishBatch(BaseModel): + """A batch of entries to publish as a PR.""" + + group_key: str = Field(description="Batch group key (IDS name or domain)") + entries: list[SNPublishEntry] + confidence_tier: str = Field(description="high, medium, or low") + + +# ============================================================================= +# Cross-model review models +# ============================================================================= + + +class SNReviewVerdict(StrEnum): + """Review decision for a standard name candidate.""" + + accept = "accept" + reject = "reject" + revise = "revise" + + +class SNReviewItem(BaseModel): + """Review of a single standard name candidate.""" + + source_id: str = Field(description="Source entity ID being reviewed") + standard_name: str = Field(description="The standard name under review") + verdict: SNReviewVerdict = Field(description="Accept, reject, or revise") + confidence: float = Field(ge=0, le=1, description="Review confidence") + reason: str = Field(description="Justification for the verdict") + revised_name: str | None = Field( + default=None, description="Suggested revision if verdict is revise" + ) + revised_fields: dict[str, str] | None = Field( + default=None, description="Revised grammar fields" + ) + issues: list[str] = Field(default_factory=list, description="Specific issues found") + + +class SNReviewBatch(BaseModel): + """LLM response for reviewing a batch of standard name candidates.""" + + reviews: list[SNReviewItem] diff --git a/imas_codex/sn/pipeline.py b/imas_codex/sn/pipeline.py new file mode 100644 index 000000000..a0b2e301a --- /dev/null +++ b/imas_codex/sn/pipeline.py @@ -0,0 +1,95 @@ +"""SN build pipeline orchestrator. + +Wires the EXTRACT → COMPOSE → [REVIEW] → VALIDATE → PERSIST workers into +the generic discovery engine and runs them with supervision and progress +tracking. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from imas_codex.discovery.base.engine import WorkerSpec, run_discovery_engine +from imas_codex.sn.state import SNBuildState +from imas_codex.sn.workers import ( + compose_worker, + extract_worker, + persist_worker, + review_worker, + validate_worker, +) + +logger = logging.getLogger(__name__) + + +async def run_sn_build_engine( + state: SNBuildState, + *, + stop_event: asyncio.Event | None = None, + on_worker_status: Any | None = None, +) -> None: + """Run the SN build pipeline. + + Pipeline:: + + EXTRACT → COMPOSE → [REVIEW] → VALIDATE → PERSIST + + Extract queries the graph for DD paths, builds cluster-based batches. + Compose uses LLM to generate standard names from the batches. + Review uses a different model family to cross-check composed names. + Validate checks grammar compliance via round-trip + fields consistency. + Persist writes validated names to graph with provenance. + + When ``state.skip_review`` is True the REVIEW phase is disabled and + its ``PipelinePhase`` is marked done immediately, so VALIDATE does + not block. + + Args: + state: Populated ``SNBuildState`` with source and filter config. + stop_event: Optional asyncio.Event for CLI shutdown signalling. + on_worker_status: Optional callback for progress display updates. + """ + # When review is skipped, validate depends directly on compose + validate_deps = ["review_phase"] if not state.skip_review else ["compose_phase"] + + workers = [ + WorkerSpec( + "extract", + "extract_phase", + extract_worker, + ), + WorkerSpec( + "compose", + "compose_phase", + compose_worker, + depends_on=["extract_phase"], + ), + WorkerSpec( + "review", + "review_phase", + review_worker, + depends_on=["compose_phase"], + enabled=not state.skip_review, + ), + WorkerSpec( + "validate", + "validate_phase", + validate_worker, + depends_on=validate_deps, + ), + WorkerSpec( + "persist", + "persist_phase", + persist_worker, + depends_on=["validate_phase"], + ), + ] + + await run_discovery_engine( + state, + workers, + stop_event=stop_event, + on_worker_status=on_worker_status, + ) diff --git a/imas_codex/sn/progress.py b/imas_codex/sn/progress.py new file mode 100644 index 000000000..db3fa7a3b --- /dev/null +++ b/imas_codex/sn/progress.py @@ -0,0 +1,158 @@ +"""Progress display for standard name build pipeline.""" + +from __future__ import annotations + +import logging +from typing import Any + +from rich.text import Text + +from imas_codex.discovery.base.progress import ( + BaseProgressDisplay, + PipelineRowConfig, + ResourceConfig, + WorkerStats, + build_pipeline_section, + build_resource_section, +) +from imas_codex.discovery.base.supervision import SupervisedWorkerGroup + +logger = logging.getLogger(__name__) + + +class SNProgressDisplay(BaseProgressDisplay): + """Rich progress display for the SN build pipeline. + + Shows five phases: Extract → Compose → Review → Validate → Persist + with per-phase progress bars, rates, and cost tracking. + """ + + def __init__( + self, + source: str = "dd", + *, + console: Any | None = None, + cost_limit: float = 5.0, + mode_label: str | None = None, + ): + super().__init__( + facility="sn", + cost_limit=cost_limit, + console=console, + title_suffix="Standard Name Build", + ) + self.source = source + self._mode_label = mode_label + self._engine_state: Any | None = None + + def set_engine_state(self, state: Any) -> None: + """Connect display to the live engine state.""" + self._engine_state = state + + def on_worker_status(self, group: SupervisedWorkerGroup) -> None: + """Callback for worker status updates.""" + self.update_worker_status(group) + + def _header_mode_label(self) -> str | None: + return self._mode_label + + def _get_stage_stats(self, attr: str) -> WorkerStats | None: + """Safely get stats from engine state.""" + if self._engine_state is None: + return None + return getattr(self._engine_state, attr, None) + + def _build_pipeline_section(self) -> Text: + """Build pipeline section showing Extract → Compose → Review → Validate → Persist.""" + stages = [ + ("EXTRACT", "bold blue", "extract", "extract_stats"), + ("COMPOSE", "bold magenta", "compose", "compose_stats"), + ("REVIEW", "bold yellow", "review", "review_stats"), + ("VALIDATE", "bold green", "validate", "validate_stats"), + ("PERSIST", "bold cyan", "persist", "persist_stats"), + ] + + rows: list[PipelineRowConfig] = [] + for name, style, group, stats_attr in stages: + stats = self._get_stage_stats(stats_attr) + count, ann = self._count_group_workers(group) + completed = stats.processed if stats else 0 + total = stats.total if stats and stats.total > 0 else max(completed, 1) + complete = self._worker_complete(group) + running = self._worker_running(group) + waiting = self._worker_waiting(group) + + primary_text = stats.status_text if stats else "" + if stats and stats._current_stream_item: + si = stats._current_stream_item + primary_text = si.get("primary_text", primary_text) + + rows.append( + PipelineRowConfig( + name=name, + style=style, + completed=completed, + total=total, + rate=stats.ema_rate if stats else None, + cost=stats.cost if stats and stats.cost > 0 else None, + worker_count=count, + worker_annotation=ann, + primary_text=primary_text, + is_complete=complete, + is_processing=running and not complete, + processing_label="waiting..." if waiting else "processing...", + ) + ) + return build_pipeline_section(rows, self.bar_width) + + def _build_resources_section(self) -> Text: + """Build resource gauges: elapsed, cost, ETA.""" + total_cost = 0.0 + stats_attrs = [ + "extract_stats", + "compose_stats", + "review_stats", + "validate_stats", + "persist_stats", + ] + + for attr in stats_attrs: + stats = self._get_stage_stats(attr) + if stats and stats.cost > 0: + total_cost += stats.cost + + config = ResourceConfig( + elapsed=self.elapsed, + run_cost=total_cost if total_cost > 0 else None, + cost_limit=self.cost_limit if self.cost_limit > 0 else None, + ) + return build_resource_section(config, self.gauge_width) + + def refresh_from_graph(self, facility: str) -> None: + """Refresh display data from graph (no-op for SN build).""" + self._refresh() + + def print_summary(self) -> None: + """Print a brief summary after build completes.""" + if self._engine_state is None: + return + + lines: list[str] = [] + for label, attr in [ + ("EXTRACT", "extract_stats"), + ("COMPOSE", "compose_stats"), + ("REVIEW", "review_stats"), + ("VALIDATE", "validate_stats"), + ("PERSIST", "persist_stats"), + ]: + stats = self._get_stage_stats(attr) + if stats and stats.processed > 0: + parts = [f"{label}: {stats.processed:,}"] + if stats.cost > 0: + parts.append(f"${stats.cost:.2f}") + lines.append(" ".join(parts)) + + if lines: + self.console.print() + for line in lines: + self.console.print(f" {line}") diff --git a/imas_codex/sn/publish.py b/imas_codex/sn/publish.py new file mode 100644 index 000000000..97823c678 --- /dev/null +++ b/imas_codex/sn/publish.py @@ -0,0 +1,376 @@ +"""Publish validated standard names to YAML catalog files. + +Converts validated StandardName graph nodes into YAML files matching +the ``imas-standard-names-catalog`` format. Supports batching by IDS, +domain, or confidence tier, and optional GitHub PR creation via ``gh``. + +Usage (from CLI):: + + imas-codex sn publish --output-dir sn_catalog_output + imas-codex sn publish --group-by ids --dry-run + imas-codex sn publish --create-pr --catalog-repo org/repo +""" + +from __future__ import annotations + +import logging +import subprocess +from pathlib import Path +from typing import Any + +import yaml + +from imas_codex.sn.models import SNProvenance, SNPublishBatch, SNPublishEntry + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Confidence tier classification +# ============================================================================= + +_HIGH_THRESHOLD = 0.8 +_MEDIUM_THRESHOLD = 0.5 + + +def confidence_tier(confidence: float) -> str: + """Classify a confidence score into high / medium / low.""" + if confidence >= _HIGH_THRESHOLD: + return "high" + if confidence >= _MEDIUM_THRESHOLD: + return "medium" + return "low" + + +# ============================================================================= +# YAML generation +# ============================================================================= + + +def generate_yaml_entry(entry: SNPublishEntry) -> str: + """Generate YAML content for a single standard name entry. + + Returns a YAML string formatted to match the + ``imas-standard-names-catalog`` convention. + """ + doc: dict[str, Any] = { + "name": entry.name, + "kind": entry.kind, + } + if entry.unit is not None: + doc["unit"] = entry.unit + if entry.tags: + doc["tags"] = entry.tags + doc["status"] = entry.status + if entry.description: + doc["description"] = entry.description + doc["provenance"] = { + "source": entry.provenance.source, + "source_id": entry.provenance.source_id, + } + if entry.provenance.ids_name: + doc["provenance"]["ids_name"] = entry.provenance.ids_name + doc["provenance"]["confidence"] = entry.provenance.confidence + doc["provenance"]["generated_by"] = entry.provenance.generated_by + + return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False).rstrip("\n") + + +def generate_catalog_files( + entries: list[SNPublishEntry], + output_dir: Path, +) -> list[Path]: + """Write YAML files to *output_dir*. One file per entry. + + File names are ``{name}.yaml`` (e.g. ``electron_temperature.yaml``). + Returns list of written file paths. + """ + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + written: list[Path] = [] + for entry in entries: + filename = f"{entry.name}.yaml" + filepath = output_dir / filename + content = generate_yaml_entry(entry) + filepath.write_text(content + "\n", encoding="utf-8") + written.append(filepath) + logger.debug("Wrote %s", filepath) + + logger.info("Generated %d YAML catalog files in %s", len(written), output_dir) + return written + + +# ============================================================================= +# Batching +# ============================================================================= + + +def batch_by_group( + entries: list[SNPublishEntry], + group_by: str = "ids", +) -> dict[str, list[SNPublishEntry]]: + """Group entries into PR batches. + + Parameters + ---------- + entries: + Publish entries to group. + group_by: + Grouping strategy — ``"ids"`` groups by IDS name from provenance, + ``"domain"`` groups by first tag, ``"confidence"`` groups by + confidence tier. + + Returns + ------- + dict mapping group key → entries in that group. + """ + groups: dict[str, list[SNPublishEntry]] = {} + + for entry in entries: + if group_by == "ids": + key = entry.provenance.ids_name or "unscoped" + elif group_by == "domain": + key = entry.tags[0] if entry.tags else "unscoped" + elif group_by == "confidence": + key = confidence_tier(entry.provenance.confidence) + else: + key = "all" + + groups.setdefault(key, []).append(entry) + + return groups + + +def make_publish_batches( + entries: list[SNPublishEntry], + group_by: str = "ids", +) -> list[SNPublishBatch]: + """Create :class:`SNPublishBatch` objects from grouped entries.""" + groups = batch_by_group(entries, group_by) + batches: list[SNPublishBatch] = [] + for key, group_entries in sorted(groups.items()): + # Determine overall confidence tier for the batch + avg_conf = ( + sum(e.provenance.confidence for e in group_entries) / len(group_entries) + if group_entries + else 0.0 + ) + batches.append( + SNPublishBatch( + group_key=key, + entries=group_entries, + confidence_tier=confidence_tier(avg_conf), + ) + ) + return batches + + +# ============================================================================= +# Duplicate checking +# ============================================================================= + + +def check_catalog_duplicates( + entries: list[SNPublishEntry], + catalog_dir: Path | None = None, +) -> tuple[list[SNPublishEntry], list[SNPublishEntry]]: + """Check for duplicates against an existing catalog directory. + + Scans ``catalog_dir`` for ``.yaml`` files and reads the ``name`` + field from each. Also detects duplicates within *entries* itself. + + Returns ``(new_entries, duplicate_entries)``. + """ + existing_names: set[str] = set() + + if catalog_dir is not None: + catalog_path = Path(catalog_dir) + if catalog_path.is_dir(): + for yaml_file in catalog_path.glob("*.yaml"): + try: + with open(yaml_file, encoding="utf-8") as f: + doc = yaml.safe_load(f) + if isinstance(doc, dict) and "name" in doc: + existing_names.add(doc["name"]) + except Exception: + logger.debug("Could not parse %s", yaml_file) + + new: list[SNPublishEntry] = [] + duplicates: list[SNPublishEntry] = [] + seen: set[str] = set() + + for entry in entries: + if entry.name in existing_names or entry.name in seen: + duplicates.append(entry) + else: + new.append(entry) + seen.add(entry.name) + + if duplicates: + logger.info( + "Found %d duplicates (%d existing catalog, %d within batch)", + len(duplicates), + sum(1 for d in duplicates if d.name in existing_names), + sum(1 for d in duplicates if d.name in seen), + ) + + return new, duplicates + + +# ============================================================================= +# Graph → SNPublishEntry conversion +# ============================================================================= + + +def graph_records_to_entries( + records: list[dict[str, Any]], +) -> list[SNPublishEntry]: + """Convert raw graph query dicts to :class:`SNPublishEntry` objects. + + Handles both schema-canonical properties (``source``, ``source_path``, + ``canonical_units``) and legacy write properties (``source_type``, + ``source_id``, ``units``). + """ + entries: list[SNPublishEntry] = [] + for rec in records: + name = rec.get("name") or rec.get("id", "") + if not name: + continue + + # Resolve source type (schema: source, legacy: source_type) + source = rec.get("source") or rec.get("source_type") or "dd" + + # Resolve source ID (schema: source_path, legacy: source_id) + source_id = rec.get("source_path") or rec.get("source_id") or "" + + # Resolve units (schema: canonical_units, legacy: units) + unit = rec.get("canonical_units") or rec.get("units") + + # IDS name from graph traversal + ids_name = rec.get("ids_name") + + # Confidence: from node or default 1.0 for grammar-validated names + confidence = rec.get("confidence") + if confidence is None: + confidence = 1.0 + + description = rec.get("description") or "" + + # Build tags from available context + tags: list[str] = [] + if ids_name: + tags.append(ids_name) + + provenance = SNProvenance( + source=str(source), + source_id=str(source_id), + ids_name=ids_name, + confidence=float(confidence), + ) + + entries.append( + SNPublishEntry( + name=name, + kind="physical", + unit=unit, + tags=tags, + status="candidate", + description=description[:500] if description else "", + provenance=provenance, + ) + ) + + return entries + + +# ============================================================================= +# PR generation (stub for gh CLI) +# ============================================================================= + + +def create_catalog_pr( + batch: SNPublishBatch, + catalog_repo: str, + branch_name: str, + yaml_files: list[Path], + dry_run: bool = False, +) -> str | None: + """Create a PR via ``gh`` CLI. Returns PR URL or ``None`` in dry-run. + + Parameters + ---------- + batch: + The publish batch (used for PR title/body). + catalog_repo: + GitHub repo slug (e.g. ``"iterorganization/imas-standard-names-catalog"``). + branch_name: + Branch to create for the PR. + yaml_files: + YAML files to include in the PR. + dry_run: + If ``True``, print what would happen without creating the PR. + """ + # Build summary table for PR body + lines = [ + f"## Standard Name Candidates — {batch.group_key}", + "", + f"Confidence tier: **{batch.confidence_tier}**", + f"Entries: **{len(batch.entries)}**", + "", + "| Name | Unit | Source | Confidence |", + "|------|------|--------|------------|", + ] + for entry in batch.entries: + unit = entry.unit or "—" + src = entry.provenance.source_id or entry.provenance.source + conf = f"{entry.provenance.confidence:.2f}" + lines.append(f"| `{entry.name}` | {unit} | {src} | {conf} |") + lines.append("") + lines.append("Generated by `imas-codex sn publish`.") + + pr_title = ( + f"feat(sn): add {len(batch.entries)} standard name candidates " + f"({batch.group_key})" + ) + pr_body = "\n".join(lines) + + if dry_run: + logger.info( + "[dry-run] Would create PR on %s:\n branch: %s\n title: %s\n files: %d", + catalog_repo, + branch_name, + pr_title, + len(yaml_files), + ) + return None + + # Attempt to create PR via gh CLI + try: + result = subprocess.run( + [ + "gh", + "pr", + "create", + "--repo", + catalog_repo, + "--head", + branch_name, + "--title", + pr_title, + "--body", + pr_body, + ], + capture_output=True, + text=True, + check=True, + ) + pr_url = result.stdout.strip() + logger.info("Created PR: %s", pr_url) + return pr_url + except FileNotFoundError: + logger.error("gh CLI not found — install GitHub CLI to create PRs") + return None + except subprocess.CalledProcessError as e: + logger.error("PR creation failed: %s", e.stderr) + return None diff --git a/imas_codex/sn/sources/__init__.py b/imas_codex/sn/sources/__init__.py new file mode 100644 index 000000000..1efa8697b --- /dev/null +++ b/imas_codex/sn/sources/__init__.py @@ -0,0 +1 @@ +"""Source extraction plugins for standard name generation.""" diff --git a/imas_codex/sn/sources/base.py b/imas_codex/sn/sources/base.py new file mode 100644 index 000000000..9c2017a65 --- /dev/null +++ b/imas_codex/sn/sources/base.py @@ -0,0 +1,38 @@ +"""Base protocol for SN extraction sources.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + + +@dataclass +class ExtractionBatch: + """A batch of candidates extracted from a source for LLM composition. + + Each batch groups related items (e.g., same IDS, same cluster, same diagnostic) + to give the LLM coherent context for generating standard names. + """ + + source: str # "dd" or "signals" + group_key: str # e.g., IDS name or diagnostic name + items: list[dict] # Source-specific extraction data + context: str # Human-readable context for the LLM prompt + existing_names: set[str] = field( + default_factory=set + ) # Known standard names for dedup + + +class ExtractionSource(Protocol): + """Protocol for source extraction plugins.""" + + def extract( + self, + *, + ids_filter: str | None = None, + domain_filter: str | None = None, + facility: str | None = None, + limit: int = 500, + ) -> list[ExtractionBatch]: + """Extract candidate batches from this source.""" + ... diff --git a/imas_codex/sn/sources/dd.py b/imas_codex/sn/sources/dd.py new file mode 100644 index 000000000..8133dedd0 --- /dev/null +++ b/imas_codex/sn/sources/dd.py @@ -0,0 +1,106 @@ +"""DD source: extract standard name candidates from IMAS Data Dictionary paths.""" + +from __future__ import annotations + +import logging + +from imas_codex.sn.sources.base import ExtractionBatch + +logger = logging.getLogger(__name__) + + +def extract_dd_candidates( + *, + ids_filter: str | None = None, + domain_filter: str | None = None, + limit: int = 500, + existing_names: set[str] | None = None, +) -> list[ExtractionBatch]: + """Extract candidate quantities from IMAS DD paths. + + Queries IMASNode paths from the graph, groups by IDS and semantic cluster, + and returns batches ready for LLM composition. + + Args: + ids_filter: Restrict to specific IDS (e.g., "equilibrium") + domain_filter: Restrict to physics domain + limit: Max paths to extract + existing_names: Known standard names for dedup awareness + + Returns: + List of ExtractionBatch objects grouped by IDS + """ + from imas_codex.graph.client import GraphClient + + if existing_names is None: + existing_names = set() + + with GraphClient() as gc: + # Query leaf data paths with descriptions + params: dict = {"limit": limit} + where_parts = [ + "n.node_type = 'dynamic'", + "n.description IS NOT NULL", + "n.description <> ''", + ] + if ids_filter: + where_parts.append("ids.id = $ids_filter") + params["ids_filter"] = ids_filter + if domain_filter: + where_parts.append("n.physics_domain = $domain_filter") + params["domain_filter"] = domain_filter + + where_clause = " AND ".join(where_parts) + + results = list( + gc.query( + f""" + MATCH (n:IMASNode)-[:IN_IDS]->(ids:IDS) + WHERE {where_clause} + WITH n, ids + OPTIONAL MATCH (n)-[:IN_CLUSTER]->(c:IMASSemanticCluster) + RETURN n.id AS path, n.description AS description, + n.units AS units, n.data_type AS data_type, + n.physics_domain AS physics_domain, + ids.id AS ids_name, + c.label AS cluster_label, c.id AS cluster_id + ORDER BY ids.id, n.id + LIMIT $limit + """, + **params, + ) + ) + + if not results: + logger.info("No DD paths found matching filters") + return [] + + # Group by IDS name for coherent batches + groups: dict[str, list[dict]] = {} + for row in results: + ids_name = row["ids_name"] + groups.setdefault(ids_name, []).append(dict(row)) + + batches = [] + for ids_name, items in groups.items(): + # Build context summary for the LLM + cluster_labels = sorted( + {i["cluster_label"] for i in items if i.get("cluster_label")} + ) + context = f"IDS: {ids_name}" + if cluster_labels: + context += f"\nSemantic clusters: {', '.join(cluster_labels[:10])}" + context += f"\n{len(items)} data paths with physics quantities" + + batches.append( + ExtractionBatch( + source="dd", + group_key=ids_name, + items=items, + context=context, + existing_names=existing_names, + ) + ) + + logger.info("Extracted %d batches from %d DD paths", len(batches), len(results)) + return batches diff --git a/imas_codex/sn/sources/signals.py b/imas_codex/sn/sources/signals.py new file mode 100644 index 000000000..e36a516e6 --- /dev/null +++ b/imas_codex/sn/sources/signals.py @@ -0,0 +1,97 @@ +"""Signals source: extract standard name candidates from FacilitySignal nodes.""" + +from __future__ import annotations + +import logging + +from imas_codex.sn.sources.base import ExtractionBatch + +logger = logging.getLogger(__name__) + + +def extract_signal_candidates( + *, + facility: str, + domain_filter: str | None = None, + limit: int = 500, + existing_names: set[str] | None = None, +) -> list[ExtractionBatch]: + """Extract candidate quantities from facility signals. + + Queries FacilitySignal nodes from the graph, groups by physics domain, + and returns batches ready for LLM composition. + + Args: + facility: Facility identifier (e.g., "tcv", "jet") + domain_filter: Restrict to physics domain + limit: Max signals to extract + existing_names: Known standard names for dedup awareness + + Returns: + List of ExtractionBatch objects grouped by physics domain + """ + from imas_codex.graph.client import GraphClient + + if existing_names is None: + existing_names = set() + + with GraphClient() as gc: + params: dict = {"facility": facility, "limit": limit} + where_parts = [ + "s.facility_id = $facility", + "s.description IS NOT NULL", + "s.description <> ''", + ] + if domain_filter: + where_parts.append("s.physics_domain = $domain_filter") + params["domain_filter"] = domain_filter + + where_clause = " AND ".join(where_parts) + + results = list( + gc.query( + f""" + MATCH (s:FacilitySignal) + WHERE {where_clause} + OPTIONAL MATCH (s)-[:MEASURES]->(sn:StandardName) + RETURN s.id AS signal_id, s.description AS description, + s.physics_domain AS physics_domain, + s.canonical_units AS units, + sn.id AS existing_standard_name + ORDER BY s.physics_domain, s.id + LIMIT $limit + """, + **params, + ) + ) + + if not results: + logger.info("No signals found for %s matching filters", facility) + return [] + + # Filter out signals that already have standard names + unmapped = [r for r in results if not r.get("existing_standard_name")] + + # Group by physics domain + groups: dict[str, list[dict]] = {} + for row in unmapped: + domain = row.get("physics_domain") or "unknown" + groups.setdefault(domain, []).append(dict(row)) + + batches = [] + for domain, items in groups.items(): + context = f"Facility: {facility}, Domain: {domain}" + context += f"\n{len(items)} signals without standard names" + + batches.append( + ExtractionBatch( + source="signals", + group_key=f"{facility}/{domain}", + items=items, + context=context, + existing_names=existing_names, + ) + ) + + logger.info("Extracted %d batches from %d signals", len(batches), len(unmapped)) + return batches diff --git a/imas_codex/sn/state.py b/imas_codex/sn/state.py new file mode 100644 index 000000000..7ff9e7072 --- /dev/null +++ b/imas_codex/sn/state.py @@ -0,0 +1,91 @@ +"""SN build pipeline state. + +Extends :class:`DiscoveryStateBase` with per-phase stats and pipeline +phases for the EXTRACT → COMPOSE → VALIDATE → PERSIST standard-name +pipeline. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from imas_codex.discovery.base.progress import WorkerStats +from imas_codex.discovery.base.state import DiscoveryStateBase +from imas_codex.discovery.base.supervision import PipelinePhase + + +@dataclass +class SNBuildState(DiscoveryStateBase): + """Shared state for the standard-name build pipeline. + + Each phase has its own ``WorkerStats`` and ``PipelinePhase``. + Workers update stats and shared data as they progress. + + State fields are all past tense, named after the phase that writes them: + ``extracted``, ``composed``, ``reviewed``, ``validated``. + + The ``facility`` field is inherited from ``DiscoveryStateBase``. + For DD source pipelines use ``facility="dd"``; for signal source + pipelines pass the real facility identifier. + """ + + # Build configuration + source: str = "dd" # "dd" or "signals" + ids_filter: str | None = None # For DD source: restrict to a single IDS + domain_filter: str | None = None # Physics domain filter + facility_filter: str | None = None # For signals source: facility to query + dry_run: bool = False + force: bool = False # Bypass source-level skip + limit: int | None = None # Cap on paths to process + + # Review configuration + skip_review: bool = False + review_model: str | None = None + + # In-memory pipeline data (extract → compose → review → validate) + extracted: list[Any] = field(default_factory=list) # ExtractionBatch objects + composed: list[dict[str, Any]] = field(default_factory=list) + reviewed: list[dict[str, Any]] = field(default_factory=list) + validated: list[dict[str, Any]] = field(default_factory=list) + + # Accumulated results + stats: dict[str, Any] = field(default_factory=dict) + + # Per-phase progress (observed by display) + extract_stats: WorkerStats = field(default_factory=WorkerStats) + compose_stats: WorkerStats = field(default_factory=WorkerStats) + review_stats: WorkerStats = field(default_factory=WorkerStats) + validate_stats: WorkerStats = field(default_factory=WorkerStats) + persist_stats: WorkerStats = field(default_factory=WorkerStats) + + # Pipeline phases + extract_phase: PipelinePhase = field(init=False) + compose_phase: PipelinePhase = field(init=False) + review_phase: PipelinePhase = field(init=False) + validate_phase: PipelinePhase = field(init=False) + persist_phase: PipelinePhase = field(init=False) + + def __post_init__(self) -> None: + self.extract_phase = PipelinePhase("extract") + self.compose_phase = PipelinePhase("compose") + self.review_phase = PipelinePhase("review") + self.validate_phase = PipelinePhase("validate") + self.persist_phase = PipelinePhase("persist") + + # ------------------------------------------------------------------ + # Cost / stopping + # ------------------------------------------------------------------ + + @property + def total_cost(self) -> float: + """Total LLM cost — compose and review phases call the LLM.""" + return self.compose_stats.cost + self.review_stats.cost + + def should_stop(self) -> bool: + """Stop when base conditions met or budget exhausted.""" + if super().should_stop(): + return True + if self.budget_exhausted: + return True + return False diff --git a/imas_codex/sn/workers.py b/imas_codex/sn/workers.py new file mode 100644 index 000000000..b81d8d33a --- /dev/null +++ b/imas_codex/sn/workers.py @@ -0,0 +1,690 @@ +"""Async workers for the standard-name build pipeline. + +Four-phase pipeline (review optional, persist new): + + EXTRACT → COMPOSE → [REVIEW] → VALIDATE → PERSIST + +- **extract**: queries graph for DD paths or facility signals, builds batches +- **compose**: LLM-generates standard names from extraction batches +- **review**: cross-model review of composed candidates (optional) +- **validate**: validates names against grammar via round-trip + fields check +- **persist**: writes validated names to graph with provenance + +Workers follow the ``dd_workers.py`` pattern: each is an async function +with signature ``async def worker(state, **_kwargs)`` that updates stats, +marks phases done, and respects ``state.should_stop()``. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from imas_codex.sn.sources.base import ExtractionBatch + from imas_codex.sn.state import SNBuildState + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# EXTRACT phase +# ============================================================================= + + +async def extract_worker(state: SNBuildState, **_kwargs) -> None: + """Extract candidate quantities from graph entities into batches. + + For DD source: queries IMASNode paths, groups by cluster/IDS/prefix. + Skips sources already linked via HAS_STANDARD_NAME unless --force. + Stores ExtractionBatch objects in ``state.extracted``. + """ + from imas_codex.cli.logging import WorkerLogAdapter + + wlog = WorkerLogAdapter(logger, worker_name="sn_extract_worker") + wlog.info("Starting extraction (source=%s)", state.source) + + def _run() -> list: + from imas_codex.sn.graph_ops import ( + get_existing_standard_names, + get_named_source_ids, + ) + from imas_codex.sn.sources.dd import extract_dd_candidates + + existing = get_existing_standard_names() + + # Source-level skip for resumability + named_ids: set[str] = set() + if not state.force: + named_ids = get_named_source_ids() + if named_ids: + wlog.info("Skipping %d already-named sources", len(named_ids)) + + if state.source == "dd": + batches = extract_dd_candidates( + ids_filter=state.ids_filter, + domain_filter=state.domain_filter, + limit=state.limit or 500, + existing_names=existing, + ) + else: + wlog.error("Unknown source: %s", state.source) + return [] + + # Filter out already-named sources from batches + if named_ids and not state.force: + for batch in batches: + batch.items = [ + item + for item in batch.items + if item.get("path", item.get("signal_id")) not in named_ids + ] + # Remove empty batches + batches = [b for b in batches if b.items] + + return batches + + batches = await asyncio.to_thread(_run) + + total_items = sum(len(b.items) for b in batches) + state.extracted = batches + state.extract_stats.total = total_items + state.extract_stats.processed = total_items + state.extract_stats.record_batch(total_items) + + wlog.info( + "Extraction complete: %d batches, %d items", + len(batches), + total_items, + ) + state.stats["extract_batches"] = len(batches) + state.stats["extract_count"] = total_items + + state.extract_stats.freeze_rate() + state.extract_phase.mark_done() + + +# ============================================================================= +# COMPOSE phase +# ============================================================================= + + +async def compose_worker(state: SNBuildState, **_kwargs) -> None: + """LLM-generate standard names from extracted batches. + + Uses acall_llm_structured() with system/user prompt split for + prompt caching. Runs batches concurrently with semaphore. + Results stored in ``state.composed``. + """ + from imas_codex.cli.logging import WorkerLogAdapter + + wlog = WorkerLogAdapter(logger, worker_name="sn_compose_worker") + + total_items = sum(len(b.items) for b in state.extracted) + + if state.dry_run: + wlog.info("Dry run — skipping composition for %d items", total_items) + state.compose_stats.total = total_items + state.compose_stats.processed = total_items + state.stats["compose_skipped"] = True + state.compose_stats.freeze_rate() + state.compose_phase.mark_done() + return + + if not state.extracted: + wlog.info("No batches to compose — skipping") + state.compose_stats.freeze_rate() + state.compose_phase.mark_done() + return + + from imas_codex.discovery.base.llm import acall_llm_structured + from imas_codex.llm.prompt_loader import render_prompt + from imas_codex.settings import get_model + from imas_codex.sn.context import build_compose_context + from imas_codex.sn.models import SNComposeBatch + + model = get_model("language") + context = build_compose_context() + + # Render system prompt once (cached via prompt caching) + system_prompt = render_prompt("sn/compose_system", context) + + wlog.info( + "Composing standard names for %d items in %d batches (model=%s)", + total_items, + len(state.extracted), + model, + ) + state.compose_stats.total = total_items + + sem = asyncio.Semaphore(5) + + async def _compose_batch(batch: ExtractionBatch) -> list[dict]: + async with sem: + if state.should_stop(): + return [] + + user_context = { + "items": batch.items, + "ids_name": batch.group_key, + "existing_names": sorted(batch.existing_names)[:200], + "cluster_context": batch.context, + } + user_prompt = render_prompt("sn/compose_dd", {**context, **user_context}) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + result, cost, tokens = await acall_llm_structured( + model=model, + messages=messages, + response_model=SNComposeBatch, + ) + + state.compose_stats.cost += cost + state.compose_stats.processed += len(batch.items) + state.compose_stats.record_batch(len(batch.items)) + + candidates = [] + for c in result.candidates: + candidates.append( + { + "id": c.standard_name, + "source_type": "dd" if state.source == "dd" else "signal", + "source_id": c.source_id, + "fields": c.fields, + "confidence": c.confidence, + "reason": c.reason, + } + ) + + wlog.info( + "Batch %s: %d composed, %d skipped (cost=$%.4f)", + batch.group_key, + len(result.candidates), + len(result.skipped), + cost, + ) + return candidates + + tasks = [_compose_batch(batch) for batch in state.extracted] + results = await asyncio.gather(*tasks, return_exceptions=True) + + composed: list[dict] = [] + errors = 0 + for r in results: + if isinstance(r, list): + composed.extend(r) + elif isinstance(r, Exception): + errors += 1 + wlog.warning("Batch failed: %s", r) + + state.composed = composed + state.compose_stats.errors = errors + + wlog.info( + "Composition complete: %d composed, %d errors (cost=$%.4f)", + len(composed), + errors, + state.compose_stats.cost, + ) + state.stats["compose_count"] = len(composed) + state.stats["compose_errors"] = errors + + state.compose_stats.freeze_rate() + state.compose_phase.mark_done() + + +# ============================================================================= +# REVIEW phase +# ============================================================================= + +# Default batch size for review LLM calls +_REVIEW_BATCH_SIZE = 10 + + +def _get_grammar_enums() -> dict[str, list[str]]: + """Return grammar enum values for prompt context.""" + from imas_standard_names.grammar import ( + BinaryOperator, + Component, + GeometricBase, + Object, + Position, + Process, + Subject, + Transformation, + ) + + return { + "subjects": [e.value for e in Subject], + "components": [e.value for e in Component], + "coordinates": [e.value for e in Component], # same enum + "positions": [e.value for e in Position], + "processes": [e.value for e in Process], + "transformations": [e.value for e in Transformation], + "geometric_bases": [e.value for e in GeometricBase], + "objects": [e.value for e in Object], + "binary_operators": [e.value for e in BinaryOperator], + } + + +async def review_worker(state: SNBuildState, **_kwargs) -> None: + """Cross-model review of composed standard name candidates. + + Uses a different LLM model family to review candidates produced by + the compose phase. Applies accept/reject/revise verdicts. + + Results stored in ``state.reviewed``; validate reads from reviewed + (falling back to composed when review is skipped). + """ + from imas_codex.cli.logging import WorkerLogAdapter + + wlog = WorkerLogAdapter(logger, worker_name="sn_review_worker") + + if state.dry_run: + wlog.info("Dry run — skipping review for %d candidates", len(state.composed)) + state.reviewed = list(state.composed) + state.review_stats.total = len(state.composed) + state.review_stats.processed = len(state.composed) + state.stats["review_skipped"] = True + state.review_stats.freeze_rate() + state.review_phase.mark_done() + return + + if not state.composed: + wlog.info("No composed candidates to review — skipping") + state.review_stats.freeze_rate() + state.review_phase.mark_done() + return + + wlog.info("Reviewing %d composed candidates", len(state.composed)) + state.review_stats.total = len(state.composed) + + from imas_codex.settings import get_model + + review_model = state.review_model or get_model("reasoning") + wlog.info("Review model: %s", review_model) + + grammar_enums = _get_grammar_enums() + existing_names = _get_existing_names_for_review() + + accepted: list[dict] = [] + rejected = 0 + revised = 0 + errors = 0 + total_cost = 0.0 + total_tokens = 0 + names_in_run: set[str] = set(existing_names) + + candidates = list(state.composed) + for batch_start in range(0, len(candidates), _REVIEW_BATCH_SIZE): + if state.should_stop(): + wlog.info("Stop requested at batch starting %d", batch_start) + break + + batch = candidates[batch_start : batch_start + _REVIEW_BATCH_SIZE] + + try: + batch_result = await _review_batch( + batch, + review_model, + grammar_enums, + names_in_run, + wlog, + ) + batch_accepted, batch_rejected, batch_revised, cost, tokens = batch_result + total_cost += cost + total_tokens += tokens + + for entry in batch_accepted: + name = entry.get("id", "") + if name and name not in names_in_run: + accepted.append(entry) + names_in_run.add(name) + elif name in names_in_run: + wlog.debug("Duplicate name after review: %r — skipping", name) + rejected += 1 + else: + accepted.append(entry) + + rejected += batch_rejected + revised += batch_revised + + except Exception: + wlog.debug("Review batch failed at offset %d", batch_start, exc_info=True) + errors += len(batch) + accepted.extend(batch) + + state.review_stats.processed = min(batch_start + len(batch), len(candidates)) + state.review_stats.record_batch(len(batch)) + + state.review_stats.errors = errors + state.review_stats.cost = total_cost + state.reviewed = accepted + + wlog.info( + "Review complete: %d accepted, %d rejected, %d revised, %d errors " + "(cost: $%.4f, tokens: %d)", + len(accepted), + rejected, + revised, + errors, + total_cost, + total_tokens, + ) + state.stats["review_accepted"] = len(accepted) + state.stats["review_rejected"] = rejected + state.stats["review_revised"] = revised + state.stats["review_errors"] = errors + state.stats["review_cost"] = total_cost + + state.review_stats.freeze_rate() + state.review_phase.mark_done() + + +def _get_existing_names_for_review() -> set[str]: + """Fetch existing standard names from graph for duplicate checking.""" + try: + from imas_codex.sn.graph_ops import get_existing_standard_names + + return get_existing_standard_names() + except Exception: + return set() + + +async def _review_batch( + batch: list[dict], + model: str, + grammar_enums: dict[str, list[str]], + existing_names: set[str], + wlog: logging.LoggerAdapter, +) -> tuple[list[dict], int, int, float, int]: + """Review a single batch of candidates via LLM.""" + from imas_codex.discovery.base.llm import acall_llm_structured + from imas_codex.llm.prompt_loader import render_prompt + from imas_codex.sn.models import SNReviewBatch, SNReviewVerdict + + context = { + "items": batch, + "existing_names": sorted(existing_names)[:200], + **grammar_enums, + } + prompt_text = render_prompt("sn/review", context) + + messages = [{"role": "user", "content": prompt_text}] + result, cost, tokens = await acall_llm_structured( + model=model, + messages=messages, + response_model=SNReviewBatch, + ) + + entry_map: dict[str, dict] = {} + for entry in batch: + sid = entry.get("source_id") or entry.get("id") or "" + entry_map[sid] = entry + + accepted: list[dict] = [] + rejected_count = 0 + revised_count = 0 + + for review in result.reviews: + original = entry_map.get(review.source_id) + if original is None: + wlog.debug("Review returned unknown source_id: %s", review.source_id) + continue + + if review.verdict == SNReviewVerdict.accept: + accepted.append(original) + elif review.verdict == SNReviewVerdict.reject: + rejected_count += 1 + wlog.debug("Rejected %r: %s", review.standard_name, review.reason) + elif review.verdict == SNReviewVerdict.revise: + if review.revised_name: + revised_entry = dict(original) + revised_entry["id"] = review.revised_name + if review.revised_fields: + for key, value in review.revised_fields.items(): + if key in revised_entry: + revised_entry[key] = value + accepted.append(revised_entry) + revised_count += 1 + wlog.debug( + "Revised %r → %r: %s", + review.standard_name, + review.revised_name, + review.reason, + ) + else: + accepted.append(original) + + reviewed_ids = {r.source_id for r in result.reviews} + for entry in batch: + sid = entry.get("source_id") or entry.get("id") or "" + if sid not in reviewed_ids: + accepted.append(entry) + + return accepted, rejected_count, revised_count, cost, tokens + + +# ============================================================================= +# VALIDATE phase +# ============================================================================= + + +async def validate_worker(state: SNBuildState, **_kwargs) -> None: + """Validate composed names against grammar via round-trip + fields check. + + Reads from ``state.reviewed`` if review ran, else ``state.composed``. + Reports distinct metrics: validate_valid, validate_invalid, + validate_fields_consistent, validate_fields_inconsistent. + Results stored in ``state.validated``. + """ + from imas_codex.cli.logging import WorkerLogAdapter + + wlog = WorkerLogAdapter(logger, worker_name="sn_validate_worker") + + if state.dry_run: + wlog.info("Dry run — skipping validation") + count = sum(len(b.items) for b in state.extracted) if state.extracted else 0 + state.validate_stats.total = count + state.validate_stats.processed = count + state.stats["validate_skipped"] = True + state.validate_stats.freeze_rate() + state.validate_phase.mark_done() + return + + # Read from reviewed if available, else composed + input_candidates = state.reviewed if state.reviewed else state.composed + + if not input_candidates: + wlog.info("No composed names to validate — skipping") + state.validate_stats.freeze_rate() + state.validate_phase.mark_done() + return + + from imas_standard_names.grammar import ( + StandardName, + compose_standard_name, + parse_standard_name, + ) + + wlog.info("Validating %d composed names", len(input_candidates)) + state.validate_stats.total = len(input_candidates) + + valid: list[dict] = [] + invalid_count = 0 + fields_consistent = 0 + fields_inconsistent = 0 + + for i, entry in enumerate(input_candidates): + name = entry.get("id", "") + try: + parsed = parse_standard_name(name) + normalized = compose_standard_name(parsed) + if normalized != name: + wlog.debug("Normalization: %r → %r", name, normalized) + entry["id"] = normalized + + # Fields consistency check + fields = entry.get("fields", {}) + if fields: + try: + sn_fields = _convert_fields_to_grammar(fields) + if sn_fields: + sn = StandardName(**sn_fields) + from_fields = compose_standard_name(sn) + if from_fields == normalized: + fields_consistent += 1 + entry["fields_consistent"] = True + else: + fields_inconsistent += 1 + entry["fields_consistent"] = False + wlog.debug( + "Fields inconsistent: %r vs %r", + from_fields, + normalized, + ) + except Exception: + fields_inconsistent += 1 + entry["fields_consistent"] = False + + valid.append(entry) + except Exception: + wlog.debug("Validation failed for name: %r", name) + invalid_count += 1 + + state.validate_stats.processed = i + 1 + + if valid: + state.validate_stats.record_batch(len(valid)) + state.validate_stats.errors = invalid_count + state.validated = valid + + wlog.info( + "Validation complete: %d valid, %d invalid, " + "%d fields consistent, %d fields inconsistent", + len(valid), + invalid_count, + fields_consistent, + fields_inconsistent, + ) + state.stats["validate_valid"] = len(valid) + state.stats["validate_invalid"] = invalid_count + state.stats["validate_fields_consistent"] = fields_consistent + state.stats["validate_fields_inconsistent"] = fields_inconsistent + + state.validate_stats.freeze_rate() + state.validate_phase.mark_done() + + +def _convert_fields_to_grammar(fields: dict) -> dict: + """Convert string field values to grammar enum instances.""" + from imas_standard_names.grammar import ( + BinaryOperator, + Component, + GeometricBase, + Object, + Position, + Process, + Subject, + Transformation, + ) + + enum_map = { + "subject": Subject, + "component": Component, + "coordinate": Component, + "position": Position, + "process": Process, + "transformation": Transformation, + "geometric_base": GeometricBase, + "object": Object, + "binary_operator": BinaryOperator, + } + + sn_fields: dict = {} + for k, v in fields.items(): + if k == "physical_base": + sn_fields[k] = v + elif k in enum_map: + sn_fields[k] = enum_map[k](v) + return sn_fields + + +# ============================================================================= +# PERSIST phase +# ============================================================================= + + +async def persist_worker(state: SNBuildState, **_kwargs) -> None: + """Write validated standard names to graph with provenance. + + Creates StandardName nodes and HAS_STANDARD_NAME relationships. + Enriches entries with model name, generation timestamp, review status. + """ + from imas_codex.cli.logging import WorkerLogAdapter + + wlog = WorkerLogAdapter(logger, worker_name="sn_persist_worker") + + if state.dry_run: + wlog.info( + "Dry run — skipping persist for %d validated names", len(state.validated) + ) + state.persist_stats.total = len(state.validated) + state.persist_stats.processed = len(state.validated) + state.stats["persist_skipped"] = True + state.persist_stats.freeze_rate() + state.persist_phase.mark_done() + return + + if not state.validated: + wlog.info("No validated names to persist — skipping") + state.persist_stats.freeze_rate() + state.persist_phase.mark_done() + return + + from imas_codex.settings import get_model + from imas_codex.sn.graph_ops import write_standard_names + + model = get_model("language") + now = datetime.now(UTC).isoformat() + + # Enrich with provenance + for entry in state.validated: + entry.setdefault("model", model) + entry.setdefault("review_status", "skipped") + entry.setdefault("generated_at", now) + # confidence comes from LLM output — never default to 1.0 + + # Extract grammar fields into top-level properties for graph + fields = entry.get("fields", {}) + for field_name in ( + "physical_base", + "subject", + "component", + "coordinate", + "position", + "process", + "geometric_base", + "object", + ): + if field_name in fields and field_name not in entry: + entry[field_name] = fields[field_name] + + wlog.info("Persisting %d validated standard names", len(state.validated)) + state.persist_stats.total = len(state.validated) + + written = await asyncio.to_thread(write_standard_names, state.validated) + + state.persist_stats.processed = written + state.persist_stats.record_batch(written) + state.stats["persist_written"] = written + + wlog.info("Persist complete: %d written", written) + state.persist_stats.freeze_rate() + state.persist_phase.mark_done() diff --git a/imas_codex/tools/graph_search.py b/imas_codex/tools/graph_search.py index 13f3b40e5..4a136a48f 100644 --- a/imas_codex/tools/graph_search.py +++ b/imas_codex/tools/graph_search.py @@ -4,15 +4,20 @@ GraphClient queries against Neo4j vector indexes and Cypher traversals. """ +from __future__ import annotations + import json import logging -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal from fastmcp import Context from imas_codex.core.data_model import IdsNode from imas_codex.graph.client import GraphClient from imas_codex.models.constants import SearchMode + +if TYPE_CHECKING: + from imas_codex.search.fuzzy_matcher import PathFuzzyMatcher from imas_codex.models.result_models import ( CheckPathsResult, CheckPathsResultItem, @@ -225,6 +230,11 @@ async def search_dd_paths( error="Query cannot be empty.", ) + from imas_codex.core.paths import normalize_imas_path + + # Normalize dot-notation and bracket notation before any analysis + query = normalize_imas_path(query) + normalized_filter = normalize_ids_filter(ids_filter) # Determine whether to exclude summary IDS paths. @@ -521,9 +531,31 @@ def _embed_query(self, query: str) -> list[float]: class GraphPathTool: """Graph-backed path validation and fetching.""" + _fuzzy_cache: dict[int | None, PathFuzzyMatcher] = {} + def __init__(self, graph_client: GraphClient): self._gc = graph_client + def _get_fuzzy_matcher(self, dd_version: int | None = None) -> PathFuzzyMatcher: + """Lazy-init a version-scoped PathFuzzyMatcher.""" + if dd_version not in self._fuzzy_cache: + from imas_codex.search.fuzzy_matcher import PathFuzzyMatcher + + dd_params: dict[str, Any] = {} + dd_clause = _dd_version_clause("p", dd_version, dd_params) + rows = self._gc.query( + f""" + MATCH (p:IMASNode) + WHERE p.node_category = 'data' {dd_clause} + RETURN p.id AS id, p.ids AS ids + """, + **dd_params, + ) + paths = [r["id"] for r in rows] if rows else [] + ids_names = sorted({r["ids"] for r in rows if r["ids"]}) if rows else [] + self._fuzzy_cache[dd_version] = PathFuzzyMatcher(ids_names, paths) + return self._fuzzy_cache[dd_version] + @property def tool_name(self) -> str: return "path_tool" @@ -543,7 +575,7 @@ async def check_dd_paths( dd_version: int | None = None, ctx: Context | None = None, ) -> CheckPathsResult: - """Validate IMAS paths against graph.""" + """Validate IMAS paths against graph using batch UNWIND.""" path_list = _normalize_paths(paths) if ids: path_list = [ @@ -554,22 +586,33 @@ async def check_dd_paths( dd_params: dict[str, Any] = {} dd_clause = _dd_version_clause("p", dd_version, dd_params) + # Batch existence + rename check in a single UNWIND query + rows = self._gc.query( + f""" + UNWIND $paths AS check_path + OPTIONAL MATCH (p:IMASNode {{id: check_path}}) + WHERE true {dd_clause} + OPTIONAL MATCH (p)-[:HAS_UNIT]->(u:Unit) + OPTIONAL MATCH (old:IMASNode {{id: check_path}})-[:RENAMED_TO]->(new:IMASNode) + RETURN check_path, + p.id AS id, p.ids AS ids, p.data_type AS data_type, + u.id AS units, + old.id AS renamed_from, new.id AS renamed_to + """, + paths=path_list, + **dd_params, + ) + + row_map: dict[str, dict] = {} + for r in rows or []: + row_map[r["check_path"]] = r + results = [] found = 0 + not_found_paths = [] for path in path_list: - row = self._gc.query( - f""" - MATCH (p:IMASNode {{id: $path}}) - WHERE true {dd_clause} - OPTIONAL MATCH (p)-[:HAS_UNIT]->(u:Unit) - RETURN p.id AS id, p.ids AS ids, p.data_type AS data_type, - u.id AS units - """, - path=path, - **dd_params, - ) - if row: - r = row[0] + r = row_map.get(path) + if r and r["id"]: results.append( CheckPathsResultItem( path=r["id"], @@ -580,38 +623,46 @@ async def check_dd_paths( ) ) found += 1 - else: - # Check for deprecated/renamed path - renamed = self._gc.query( - """ - MATCH (old:IMASNode {id: $path})-[:RENAMED_TO]->(new:IMASNode) - RETURN old.id AS old_path, new.id AS new_path - """, - path=path, + elif r and r["renamed_to"]: + new_path = r["renamed_to"] + results.append( + CheckPathsResultItem( + path=path, + exists=False, + renamed_from=[ + {"old_path": r["renamed_from"], "new_path": new_path} + ], + migration={"type": "renamed", "target": new_path}, + suggestion=new_path, + ) ) - if renamed: - new_path = renamed[0]["new_path"] - results.append( - CheckPathsResultItem( - path=path, - exists=False, - renamed_from=[ - { - "old_path": renamed[0]["old_path"], - "new_path": new_path, - } - ], - migration={"type": "renamed", "target": new_path}, - suggestion=new_path, - ) + else: + not_found_paths.append(path) + results.append( + CheckPathsResultItem( + path=path, + exists=False, ) - else: - results.append( - CheckPathsResultItem( - path=path, - exists=False, + ) + + # Fuzzy suggestions for paths not found and not renamed + if not_found_paths: + try: + matcher = self._get_fuzzy_matcher(dd_version) + for item in results: + if ( + not item.exists + and item.path in not_found_paths + and not item.suggestion + ): + suggestions = matcher.suggest_paths( + item.path, max_suggestions=3 ) - ) + if suggestions: + item.suggestions = suggestions + item.suggestion = suggestions[0] + except Exception: + logger.debug("Fuzzy matching unavailable", exc_info=True) return CheckPathsResult( results=results, @@ -800,7 +851,7 @@ async def fetch_dd_paths( "path (required): Exact IMAS data path. " "dd_version: Filter by DD major version (e.g., 3 or 4). None returns all versions." ) - async def fetch_dd_error_fields( + async def fetch_error_fields( self, path: str, dd_version: int | None = None, @@ -877,9 +928,14 @@ async def list_dd_paths( max_paths: int | None = None, dd_version: int | None = None, response_profile: str = "minimal", + physics_domain: str | None = None, + node_type: str | None = None, + lifecycle_filter: str | None = None, ctx: Context | None = None, ) -> ListPathsResult: """List paths from graph.""" + from imas_codex.core.paths import normalize_imas_path + queries = paths.strip().split() results = [] @@ -887,13 +943,14 @@ async def list_dd_paths( dd_clause = _dd_version_clause("p", dd_version, dd_params) for query in queries: + query = normalize_imas_path(query) # Determine if this is an IDS name or a path prefix if "/" in query: ids_name = query.split("/")[0] prefix = query else: ids_name = query - prefix = query + prefix = None # Verify IDS exists ids_exists = self._gc.query( @@ -911,14 +968,25 @@ async def list_dd_paths( ) continue - # Query paths + # Build filter clauses leaf_filter = ( - "AND NOT p.data_type IN ['STRUCTURE', 'STRUCT_ARRAY']" + "AND NOT (p.data_type IN ['STRUCTURE', 'STRUCT_ARRAY'])" if leaf_only else "" ) limit_clause = f"LIMIT {max_paths}" if max_paths else "" + extra_filters = "" + if physics_domain: + extra_filters += " AND p.physics_domain = $physics_domain" + dd_params["physics_domain"] = physics_domain + if node_type: + extra_filters += " AND p.node_type = $node_type" + dd_params["node_type"] = node_type + if lifecycle_filter: + extra_filters += " AND p.lifecycle_status = $lifecycle_filter" + dd_params["lifecycle_filter"] = lifecycle_filter + include_metadata = response_profile != "minimal" if include_metadata: @@ -933,38 +1001,30 @@ async def list_dd_paths( else: return_clause = "RETURN p.id AS id" + # Unified query — use ids= for plain IDS names, STARTS WITH for prefixes + if prefix: + match_clause = ( + "WHERE p.id STARTS WITH $prefix AND p.node_category = 'data'" + ) + dd_params["prefix"] = prefix + ("/" if not prefix.endswith("/") else "") + else: + match_clause = "WHERE p.ids = $ids_name AND p.node_category = 'data'" + dd_params["ids_name"] = ids_name + path_results = self._gc.query( f""" MATCH (p:IMASNode) - WHERE p.id STARTS WITH $prefix - AND p.node_category = 'data' + {match_clause} {leaf_filter} + {extra_filters} {dd_clause} {return_clause} ORDER BY p.id {limit_clause} """, - prefix=prefix + ("/" if "/" not in prefix else ""), **dd_params, ) - # Also include the prefix itself if it's an exact IDS - if "/" not in prefix: - path_results = self._gc.query( - f""" - MATCH (p:IMASNode) - WHERE p.ids = $ids_name - AND p.node_category = 'data' - {leaf_filter} - {dd_clause} - {return_clause} - ORDER BY p.id - {limit_clause} - """, - ids_name=ids_name, - **dd_params, - ) - path_ids = [r["id"] for r in (path_results or [])] if not include_ids_prefix: path_ids = [ @@ -1030,12 +1090,14 @@ def tool_name(self) -> str: "Get an overview of available IMAS Interface Data Structures (IDS). " "Returns IDS names, descriptions, path counts, and physics domains. " "query: Optional filter to narrow results (e.g., 'magnetics' or 'plasma equilibrium'). " - "dd_version: Filter by DD major version (e.g., 3 or 4). None returns all versions." + "dd_version: Filter by DD major version (e.g., 3 or 4). None returns all versions. " + "include_unit_stats: If true, include unit distribution statistics." ) async def get_dd_overview( self, query: str | None = None, dd_version: int | None = None, + include_unit_stats: bool = False, ctx: Context | None = None, ) -> GetOverviewResult: """Get overview from graph.""" @@ -1129,7 +1191,36 @@ async def get_dd_overview( if r["physics_domain"]: physics_domains.add(r["physics_domain"]) - # Build tools list (query_imas_graph and get_dd_graph_schema were removed) + # Domain summary: group IDS counts and path counts by physics domain + domain_summary: dict[str, dict[str, int]] = {} + for _ids_name, stats in ids_statistics.items(): + dom = stats.get("physics_domain") or "uncategorized" + entry = domain_summary.setdefault(dom, {"ids_count": 0, "path_count": 0}) + entry["ids_count"] += 1 + entry["path_count"] += stats["path_count"] + + # Lifecycle summary + lifecycle_counts: dict[str, int] = {} + for r in ids_results or []: + lc = r.get("lifecycle_status") or "unknown" + lifecycle_counts[lc] = lifecycle_counts.get(lc, 0) + 1 + + # Unit stats (optional) + unit_stats: dict[str, int] | None = None + if include_unit_stats: + unit_rows = self._gc.query( + f""" + MATCH (p:IMASNode)-[:HAS_UNIT]->(u:Unit) + WHERE p.node_category = 'data' {dd_clause} + RETURN u.id AS unit, count(p) AS cnt + ORDER BY cnt DESC + LIMIT 30 + """, + **dd_params, + ) + unit_stats = {r["unit"]: r["cnt"] for r in (unit_rows or [])} + + # Build tools list mcp_tools = [ "search_dd_paths", "check_dd_paths", @@ -1148,16 +1239,27 @@ async def get_dd_overview( total_paths = sum(s["path_count"] for s in ids_statistics.values()) + # When no query, truncate to top 10 IDS to reduce token usage + if not query and len(all_ids) > 10: + top_ids = all_ids[:10] + top_statistics = {k: ids_statistics[k] for k in top_ids} + else: + top_ids = all_ids + top_statistics = ids_statistics + return GetOverviewResult( content=f"IMAS Data Dictionary v{current_version}: {len(all_ids)} IDS, {total_paths} total paths", - available_ids=all_ids, + available_ids=top_ids, query=query, physics_domains=sorted(physics_domains), - ids_statistics=ids_statistics, + ids_statistics=top_statistics, mcp_tools=mcp_tools, dd_version=current_version, mcp_version=version, total_leaf_nodes=total_paths, + domain_summary=domain_summary, + lifecycle_summary=lifecycle_counts, + unit_statistics=unit_stats, ) @@ -2026,8 +2128,154 @@ async def analyze_dd_structure( return result + @mcp_tool( + "Get a rich structural overview of an IDS using efficient graph queries. " + "Returns metrics (path counts, depth), top-level sections, semantic clusters, " + "identifier schemas, COCOS fields, coordinate arrays, and data type distribution. " + "ids_name (required): IDS name to analyze (e.g. 'equilibrium', 'core_profiles'). " + "dd_version: Filter by DD major version (3 or 4). Default: latest version." + ) + @handle_errors("get_ids_structure") + async def get_ids_structure( + self, + ids_name: str, + dd_version: int | None = None, + ctx: Context | None = None, + ) -> dict[str, Any]: + """Get a rich structural overview of an IDS using efficient graph queries.""" + dd_params: dict[str, Any] = {"ids_name": ids_name} + dd_clause = _dd_version_clause("p", dd_version, dd_params) + + # Query 1: IDS metadata + metrics + top-level sections + combined = self._gc.query( + f""" + MATCH (i:IDS {{id: $ids_name}}) + OPTIONAL MATCH (p:IMASNode) + WHERE p.ids = $ids_name AND p.node_category = 'data' {dd_clause} + WITH i, + count(p) AS total, + count(CASE WHEN NOT (p.data_type IN ['STRUCTURE', 'STRUCT_ARRAY']) + THEN 1 END) AS leaves, + max(size(split(p.id, '/')) - 1) AS max_depth, + collect(CASE WHEN size(split(p.id, '/')) = 2 + THEN p END) AS top_nodes + RETURN i.name AS name, + COALESCE(i.description, i.documentation) AS description, + i.physics_domain AS physics_domain, + i.lifecycle_status AS lifecycle_status, + total, leaves, max_depth, + [n IN top_nodes WHERE n IS NOT NULL | + {{id: n.id, name: n.name, data_type: n.data_type, + doc: left(n.documentation, 80)}}] AS top_sections + """, + **dd_params, + ) + + if not combined: + return { + "ids_name": ids_name, + "error": f"IDS '{ids_name}' not found", + } + + meta = combined[0] + + # Query 2: Clusters containing paths from this IDS + clusters = self._gc.query( + f""" + MATCH (p:IMASNode)-[:IN_CLUSTER]->(c:IMASSemanticCluster) + WHERE p.ids = $ids_name {dd_clause} + WITH c, count(p) AS member_count + RETURN c.label AS label, c.scope AS scope, member_count + ORDER BY member_count DESC + LIMIT 15 + """, + **dd_params, + ) + + # Query 3: Identifier schemas used in this IDS + identifiers = self._gc.query( + f""" + MATCH (p:IMASNode)-[:HAS_IDENTIFIER_SCHEMA]->(s:IdentifierSchema) + WHERE p.ids = $ids_name {dd_clause} + WITH s, collect(p.id) AS paths, count(p) AS usage_count + RETURN s.name AS schema, usage_count, + paths[0..3] AS example_paths + ORDER BY usage_count DESC + """, + **dd_params, + ) + + # Query 4: COCOS fields + coordinate specs + cocos_coords = self._gc.query( + f""" + MATCH (p:IMASNode) + WHERE p.ids = $ids_name + AND (p.cocos_label_transformation IS NOT NULL + OR exists((p)-[:HAS_COORDINATE]->())) {dd_clause} + OPTIONAL MATCH (p)-[:HAS_COORDINATE]->(coord:IMASCoordinateSpec) + RETURN p.id AS path, + p.cocos_label_transformation AS cocos, + collect(coord.id) AS coordinates + ORDER BY p.id + """, + **dd_params, + ) + + cocos_fields = [ + {"path": r["path"], "label": r["cocos"]} + for r in (cocos_coords or []) + if r["cocos"] + ] + coord_arrays = [ + {"path": r["path"], "coordinates": r["coordinates"]} + for r in (cocos_coords or []) + if r["coordinates"] + ] + + # Query 5: Data type distribution + types = self._gc.query( + f""" + MATCH (p:IMASNode) + WHERE p.ids = $ids_name AND p.data_type IS NOT NULL {dd_clause} + RETURN p.data_type AS data_type, count(p) AS count + ORDER BY count DESC + """, + **dd_params, + ) + + total = meta.get("total", 0) + leaves = meta.get("leaves", 0) + return { + "ids_name": ids_name, + "description": meta.get("description", ""), + "physics_domain": meta.get("physics_domain", ""), + "lifecycle_status": meta.get("lifecycle_status", ""), + "metrics": { + "total_paths": total, + "leaf_count": leaves, + "structure_count": total - leaves, + "max_depth": meta.get("max_depth", 0), + }, + "top_sections": meta.get("top_sections", []), + "clusters": [ + {"label": c["label"], "scope": c["scope"], "members": c["member_count"]} + for c in (clusters or []) + ], + "identifier_schemas": [ + { + "schema": s["schema"], + "usage_count": s["usage_count"], + "examples": s["example_paths"], + } + for s in (identifiers or []) + ], + "cocos_fields": cocos_fields, + "coordinate_arrays": coord_arrays[:20], + "data_types": {t["data_type"]: t["count"] for t in (types or [])}, + } + @handle_errors("get_cocos_fields") - async def get_cocos_fields( + async def get_dd_cocos_fields( self, transformation_type: str | None = None, ids_filter: str | None = None, @@ -2095,7 +2343,7 @@ async def get_cocos_fields( "leaf_only: If true, return only leaf nodes (default false)." ) @handle_errors("export_imas_ids") - async def export_imas_ids( + async def export_dd_ids( self, ids_name: str, leaf_only: bool = False, @@ -2143,7 +2391,7 @@ async def export_imas_ids( "ids_filter: Optional IDS name filter." ) @handle_errors("export_imas_domain") - async def export_imas_domain( + async def export_dd_domain( self, domain: str, ids_filter: str | None = None, @@ -2206,13 +2454,13 @@ async def export_imas_domain( def _normalize_paths(paths: str | list[str]) -> list[str]: - """Normalize paths input to a flat list, stripping index annotations.""" + """Normalize paths input to a flat list, handling dots, annotations, and JSON.""" import json - from imas_codex.core.paths import strip_path_annotations + from imas_codex.core.paths import normalize_imas_path if isinstance(paths, list): - return [strip_path_annotations(p) for p in paths] + return [normalize_imas_path(p) for p in paths] s = paths.strip() # Handle JSON array strings from MCP transport @@ -2221,14 +2469,14 @@ def _normalize_paths(paths: str | list[str]) -> list[str]: parsed = json.loads(s) if isinstance(parsed, list): return [ - strip_path_annotations(str(p).strip()) + normalize_imas_path(str(p).strip()) for p in parsed if str(p).strip() ] except (json.JSONDecodeError, TypeError): pass raw = [p.strip() for p in s.replace(",", " ").split() if p.strip()] - return [strip_path_annotations(p) for p in raw] + return [normalize_imas_path(p) for p in raw] STRUCTURE_DATA_TYPES = ("STRUCTURE", "STRUCT_ARRAY") diff --git a/plans/README.md b/plans/README.md index a351ece12..d34ef4a77 100644 --- a/plans/README.md +++ b/plans/README.md @@ -25,6 +25,16 @@ Gap documents consolidate remaining work from completed implementation phases. T | **P3** | ~~embedding-upgrade-and-search-migration~~ | Fix embed bug, dim eval, SEARCH clause, quantization | **Done** — dim stays 256, SEARCH migrated, quantization enabled | | **P4** | [gaps-compute-orchestration.md](features/gaps-compute-orchestration.md) | Compute session orchestration remaining gaps | Low priority — Python CLI covers basics | +### Standard Names + +| Plan | Scope | Status | Depends On | +|------|-------|--------|------------| +| [standard-names/09-sn-generate.md](features/standard-names/09-sn-generate.md) | Core pipeline: EXTRACT→COMPOSE→VALIDATE→PERSIST | ✅ Done | — | +| [standard-names/11-rich-compose.md](features/standard-names/11-rich-compose.md) | Rich compose: full catalog fields, schema extension | Ready | 09 | +| [standard-names/12-catalog-import.md](features/standard-names/12-catalog-import.md) | Catalog import & bootstrap (309 entries, feedback loop) | Ready | 11 P1 | +| [standard-names/13-publish-pipeline.md](features/standard-names/13-publish-pipeline.md) | Lossless publish, round-trip, batched PRs | Ready | 11, 12 P1 | +| [standard-names/14-mcp-tools-benchmark.md](features/standard-names/14-mcp-tools-benchmark.md) | SN MCP tools + benchmark quality tiers | Ready | 11, 12 | + ### Pending plans (partially implemented) These plans are reference material for the gap documents above — not direct work items. Gaps were extracted into the active gap docs. diff --git a/plans/features/standard-names/00-implementation-order.md b/plans/features/standard-names/00-implementation-order.md index 4e3205a55..323417bfe 100644 --- a/plans/features/standard-names/00-implementation-order.md +++ b/plans/features/standard-names/00-implementation-order.md @@ -1,203 +1,93 @@ -# Implementation Order — Post-Pivot +# Standard Names — Implementation Order -**Status:** Approved -**Date:** 2025-07-18 (revised 2026-04-08) -**Decision:** Generation pipeline moves to imas-codex; imas-standard-names retains grammar, validation, catalog, website +**Approach:** Iterative. Build the simplest working pipeline first, test it +on real data, then extend based on observed quality gaps. -See `plans/research/standard-names/09-codex-pivot-analysis.md` for the full strategic analysis. +**Clean break:** The previous 309 catalog entries are discarded. All standard +names will be generated fresh from the graph. The catalog repo will be rebuilt. ---- +## Authority Model: Option B+ -## Pivot Resolution (unambiguous) +**Catalog YAML is authoritative** for reviewed entries. Graph is authoritative +for drafted candidates and operational metadata. See 12-catalog-import.md for +the full authority boundary design. -**Where does pipeline development happen?** All generation/review/minting pipeline work happens in **this repo (imas-codex)**. The imas-standard-names project does not build pipeline infrastructure. It exports grammar and validation for this project to consume. +| Owner | Scope | +|-------|-------| +| Catalog YAML | name, description, documentation, kind, unit, tags, links, ids_paths, status, constraints, validity_domain, provenance | +| Graph only | embedding, embedded_at, model, generated_at, review_status, confidence, source, source_path | +| Derived on import | grammar fields (from name parse), CANONICAL_UNITS edge (from unit string) | -**Why not in imas-standard-names?** This project already has ~8K lines of proven pipeline infrastructure: graph-as-ledger, supervised workers, LLM structured output, cost tracking, claim-based concurrency, Jinja2 prompt loading. Rebuilding this in standard-names would be duplication. +## Lifecycle -**Dependency direction:** One-way. Codex `pip install`s standard-names for grammar + validation. Standard-names never imports codex. +``` +DRAFTED (graph, LLM-generated) → PUBLISHED (catalog PR) → ACCEPTED (merged PR, imported back) +``` -**Scope:** Standard names label physics and geometric quantities from **multiple sources**, not just the IMAS Data Dictionary. Sources include: -- IMAS DD paths (IMASNode) — physics quantities defined in the data dictionary -- Facility signals (FacilitySignal) — experimentally measured quantities -- Potentially other graph entities in the future +All status values use past tense: drafted, published, accepted, rejected, skipped. -**CLI placement in codex:** `imas-codex sn` — a **top-level command group**, not nested under `imas` or `discover`. Standard names are broader than IMAS; minting from facility signals has nothing to do with the DD. +## Consistency Notes ---- +- **Relationship direction:** `(entity)-[:HAS_STANDARD_NAME]->(sn:StandardName)` + for ALL entity types (IMASNode, FacilitySignal). Single relationship name. + The schema doc `(FacilitySignal)-[:MEASURES]->(StandardName)` is WRONG — fix in Plan 11. +- **Unit linking:** `(sn:StandardName)-[:CANONICAL_UNITS]->(u:Unit)` via canonical_units + range declaration (relationship type: CANONICAL_UNITS per schema convention). +- **Embedding:** StandardName.embedding exists in schema with vector index + `standard_name_desc_embedding`. Persist worker must call embed after write. +- **Coalesce bug:** `write_standard_names()` uses unconditional SET — re-runs + erase existing data. MUST fix before any production use. Plan 11 Phase 4a. -## Feature Summary +## Implementation Status -| ID | Feature | Complexity | Repository | Wave | -|----|---------|------------|------------|------| -| 01 | Grammar API Exports | Low | imas-standard-names | 1 | -| 03 | Grammar Extensions (Maarten) | Medium | imas-standard-names | 1 | -| 04 | JSON Schema Contract | Low | imas-standard-names | 1 (after 01) | -| 05 | SN Build Pipeline (multi-source) | High | imas-codex | 2 | -| 06 | Cross-Model Review | Medium | imas-codex | 3 | -| 07 | Benchmarking | Medium | imas-codex | 3 | -| 08 | Publish (YAML + PR) | Medium | imas-codex | 3 | +| # | Plan | Description | Status | Depends On | Enables | +|---|------|-------------|--------|------------|---------| +| 09 | sn-generate | Core pipeline EXTRACT→COMPOSE→VALIDATE→PERSIST | ✅ Done | — | 11 | +| 11 | rich-compose | Full catalog fields, schema extension, coalesce fix, tests | 📋 Ready | 09 | 12, 13, 14 | +| 12 | catalog-import | Feedback import from reviewed catalog PRs | 📋 Ready | 11 P1 | 13 P4 | +| 13 | publish-pipeline | Lossless YAML export, batched PRs | 📋 Ready | 11 (all) | 12 (feedback loop) | +| 14 | mcp-tools-benchmark | SN search/fetch/list MCP tools + benchmark quality | 📋 Ready | 11 (embedding) | — | -**Dropped:** Feature 02 (DD Path Linking for existing entries) — existing catalog entries will be archived and regenerated. Path linking happens during codex generation. +## Deployment Waves -## Dependency Graph +### Wave 1: Schema + Persist Fix + Tests (Plan 11 Phases 1+4) -``` -imas-standard-names work: imas-codex work: - -┌──────────────────┐ -│ 01: Grammar API │──────────────────────┐ -│ Exports │ │ -└────────┬─────────┘ │ pip install - │ │ - ▼ ▼ -┌──────────────────┐ ┌──────────────────────┐ -│ 04: JSON Schema │─ ─ ─ ─ ─ ─ →│ 05: SN Build Pipeline │ -│ Contract │ validates │ (sn/ module) │ -└──────────────────┘ against │ │ - │ Sources: │ -┌──────────────────┐ │ ├─ dd.py (DD) │ -│ 03: Grammar │─ ─ ─ ─ ─ ─ →│ └─ signals.py (Fac) │ -│ Extensions │ extended └──────────┬────────────┘ -│ (Maarten) │ grammar │ -└──────────────────┘ ┌──────────┼──────────┐ - │ │ │ - ▼ ▼ ▼ - ┌────────────┐ ┌────────┐ ┌────────┐ - │06: Cross │ │07: │ │08: │ - │ Model │ │Bench- │ │Publish │ - │ Review │ │mark │ │(YAML+ │ - └────────────┘ └────────┘ │ PR) │ - └────────┘ -``` +Fix the foundation before building on it: +- Extend StandardName schema with ~12 rich fields + 2 enums (kind, review_status) +- Rename review_status enum: candidate → drafted (past tense) +- Fix schema doc: FacilitySignal uses HAS_STANDARD_NAME (not MEASURES) +- Fix signals.py MEASURES query → HAS_STANDARD_NAME +- Fix unconditional SET overwrite bug (use coalesce) +- Wire CANONICAL_UNITS relationship creation +- Wire embedding generation into persist worker +- Add graph_ops unit tests (currently 0 tests) +- Add conftest.py with shared fixtures -## Wave 1: imas-standard-names (2 parallel agents) +**Agent:** architect (cross-module: schema + graph_ops + workers + tests) -| Agent | Feature | Deliverables | -|-------|---------|-------------| -| A | 01: Grammar API Exports → 04: JSON Schema Contract | Clean public API, `__all__` coverage, `py.typed`, then JSON schema export | -| B | 03: Grammar Extensions | Unary transformations, binary operators, validation rules, missing entries | +### Wave 2: LLM Compose Upgrade (Plan 11 Phases 2+3+5) + Catalog Import (Plan 12) -Agent A chains Feature 01 → 04 sequentially (04 depends on 01). Agent B works independently on Feature 03. +Run in parallel — compose upgrade and catalog import are independent after schema: +- **Agent A (architect):** Extend SNCandidate model, update prompts for rich docs, update validate worker +- **Agent B (engineer):** Build `sn import-catalog` CLI + catalog_import.py + version tracking -**Feature 01 details:** Ensure grammar module is importable as a clean library: -- `compose_standard_name()`, `parse_standard_name()` in `__all__` -- All enums (Component, Position, Subject, etc.) exported -- `validate_models()` from `services.py` exported as public API -- No side effects on import -- Verify with `python -c "from imas_standard_names.grammar import compose_standard_name"` +### Wave 3: Publish (Plan 13) + MCP Tools + Benchmark (Plan 14) -**Feature 03 details:** Grammar changes per Maarten's feedback: -- `square_of_X`, `change_over_time_in_X` unary operators -- `product_of_X_and_Y`, `ratio_of_X_to_Y` binary operators -- `flux_loop_name`, `coil_current`, `passive_current` entries -- Units `None` ≠ `dimensionless` enforcement +Run in parallel — export and tools are independent: +- **Agent A (engineer):** Fix lossy publish export, update graph query, PR workflow, dedup +- **Agent B (engineer):** 3 MCP tools (search/fetch/list) + benchmark quality tiers + reviewer -**Feature 04 details:** JSON schema contract at the project boundary: -- Export `StandardNameEntry` Pydantic JSON schema as versioned static file -- Store at `imas_standard_names/schemas/entry_schema.json` -- Include in package distribution -- Validation utility callable without full catalog +### Wave 4: Integration Testing -## Wave 2: imas-codex (parallel, 2-3 agents) +- End-to-end: build → publish → review → import +- Round-trip idempotence verification +- Embedding coverage for all StandardName nodes +- Documentation updates (AGENTS.md, README) -| Agent | Feature | Deliverables | -|-------|---------|-------------| -| A | 05: SN Build Pipeline (core) | `sn/pipeline.py`, `sn/workers.py`, `sn/state.py`, `sn/graph_ops.py` | -| B | 05: Source Plugins + Prompts | `sn/sources/dd.py`, `sn/sources/signals.py`, `llm/prompts/sn/` Jinja2 templates | -| C | 05: CLI + Schema + Progress | `cli/sn.py` (top-level group), graph schema updates, `sn/progress.py` display | +**Agent:** architect (integration testing + documentation) -**Pipeline architecture** (in codex, using existing infrastructure): -``` -imas_codex/sn/ module (NEW top-level, peer of discovery/ and graph/): - EXTRACT → COMPOSE → REVIEW → VALIDATE → PUBLISH - - Uses: run_discovery_engine(), WorkerSpec, PipelinePhase - State: SNBuildState(DiscoveryStateBase) with source, ids_filter, domain_filter, facility_filter - Graph: StandardName nodes populated on publish; transient candidates in-memory - -CLI: - imas-codex sn build --source dd --ids equilibrium --cost-limit 5 - imas-codex sn build --source signals --facility tcv - imas-codex sn status - imas-codex sn benchmark -``` - -**Progress monitoring** — leverage `discovery/base/progress.py` infrastructure: - -The SN build uses `DataDrivenProgressDisplay` with `StageDisplaySpec` for each pipeline phase. This gives the same rich terminal UI as `discover paths` and `imas dd build`: - -```python -# sn/progress.py — extends DataDrivenProgressDisplay -stages = [ - StageDisplaySpec(key="extract", label="Extract", unit="paths"), - StageDisplaySpec(key="compose", label="Compose", unit="names"), - StageDisplaySpec(key="review", label="Review", unit="names"), - StageDisplaySpec(key="validate", label="Validate", unit="names"), - StageDisplaySpec(key="publish", label="Publish", unit="entries"), -] - -# Tracks per-phase: pending/done/error counts, rate, ETA -# Tracks overall: cost, elapsed, worker status, source being processed -# ResourceConfig for LLM cost gauge and token budget -``` +## Archived Plans -Key progress components to reuse from `discovery/base/progress.py`: -- `PipelineRowConfig` — per-phase progress bar with done/pending/error counts and rate -- `WorkerStats` — EMA rate calculation, error rate tracking, scanner status -- `build_resource_section()` — cost gauge, token budget, embed queue -- `build_worker_status_section()` — live worker state (idle/running/crashed) -- `compute_parallel_eta()` — ETA for parallel worker groups -- `ProgressConfig` — source label, cost limit, deadline -- `StreamQueue` — rate-limited event stream for smooth display updates - -## Wave 3: imas-codex refinement (parallel, 3 agents) - -| Agent | Feature | Deliverables | -|-------|---------|-------------| -| A | 06: Cross-Model Review | Review phase using different LLM family, scoring, accept/reject | -| B | 07: Benchmarking | `imas-codex sn benchmark` command, model comparison tables | -| C | 08: Publish | YAML generation, batched GitHub PRs with confidence tiers to catalog repo | - -## Full Summary Table - -| # | Feature | Repo | Depends On | Enables | Wave | Agents | -|---|---------|------|-----------|---------|------|--------| -| 01 | Grammar API Exports | SN | — | 04, 05 | 1 | A | -| 03 | Grammar Extensions | SN | — | 05 | 1 | B | -| 04 | JSON Schema Contract | SN | 01 | 05 | 1 | A (after 01) | -| 05 | SN Build Pipeline | Codex | 01, 04 | 06, 07, 08 | 2 | A, B, C | -| 06 | Cross-Model Review | Codex | 05 | 08 | 3 | A | -| 07 | Benchmarking | Codex | 05 | — | 3 | B | -| 08 | Publish (YAML + PR) | Codex | 05, 06 | — | 3 | C | - -**Dropped:** Feature 02 (DD Path Linking) — existing entries archived, regenerated by pipeline. - -## Superseded Plans - -| Old ID | Old Feature | Superseded By | -|--------|-------------|---------------| -| 01 | Prompt System | Codex `llm/prompt_loader.py` + Jinja2 templates | -| 02 | LLM Pipeline Infrastructure | Codex `discovery/base/llm.py` + cost tracking | -| 03 | Batch Generation Pipeline | Codex `discovery/base/engine.py` + Feature 05 | -| 04 | CLI Dispatch Commands | Codex CLI framework + `cli/sn.py` | -| 05 (Phases 2-4) | DD Integration (generation parts) | Feature 05 in codex | - -## Risk Register - -| Risk | Impact | Mitigation | -|------|--------|------------| -| Grammar version lock between repos | High | JSON schema contract at boundary; CI tests against SN main | -| Codex infrastructure requirement | Low | Generation only needs codex; SN catalog consumers never need Neo4j | -| Cross-model review quality variance | Medium | Benchmarking phase (Feature 07) before production runs | -| PR review avalanche | Medium | Batched PRs by IDS/domain with confidence tiers | -| Name collisions with existing catalog | Medium | Catalog-aware compose phase with dedup checks | -| Binary operator grammar complexity | Medium | Design review in Feature 03 before implementation | - -## Definition of Done - -A feature is complete when: -1. All deliverables implemented with passing tests -2. 100% test coverage on new code -3. Cross-project interface verified (for features spanning repos) -4. Documentation updated (docstrings + relevant docs/) -5. Code passes `ruff check` and `ruff format` +Previous plans 09 (schema providers), 10 (pipeline fixes) are superseded. +Research material in `plans/research/standard-names/`. +Archived v1 plans in `plans/research/standard-names/archived-v1/`. diff --git a/plans/features/standard-names/09-llm-compose.md b/plans/features/standard-names/09-llm-compose.md new file mode 100644 index 000000000..547efb8df --- /dev/null +++ b/plans/features/standard-names/09-llm-compose.md @@ -0,0 +1,227 @@ +# 09: Schema Providers + LLM Compose + +**Status:** Pending +**Priority:** Critical — this is THE blocking gap +**Depends on:** Nothing (all infrastructure exists) +**Effort:** 2-3 days + +## Problem + +Two coupled problems must be solved together: + +### A. Impoverished prompt context + +The compose prompt (`sn/compose_dd.md`) receives bare enum lists from +`build_grammar_context()`: +```python +{"subjects": ["electron", "ion", ...], "positions": ["magnetic_axis", ...]} +``` + +Meanwhile, the imas-standard-names library has rich prompting infrastructure +that produces ~800 lines of structured context: +- 42 real examples grouped by composition pattern +- Segment descriptions with critical distinctions (component vs coordinate) +- Template application rules, exclusivity constraints +- Usage guidance per segment +- Tokamak parameters for grounding documentation examples +- Field schema guidance with common mistakes + +These are available as **direct Python imports** — no MCP needed: +```python +from imas_standard_names.grammar.constants import ( + SEGMENT_RULES, SEGMENT_ORDER, SEGMENT_TEMPLATES, + SEGMENT_TOKEN_MAP, EXCLUSIVE_SEGMENT_PAIRS, + APPLICABILITY_INCLUDE, GENERIC_PHYSICAL_BASES, +) +from imas_standard_names.grammar.field_schemas import ( + FIELD_GUIDANCE, TYPE_SPECIFIC_REQUIREMENTS, +) +from imas_standard_names.tools.grammar import ( + _build_canonical_pattern, _build_segment_order_constraint, + _get_segment_descriptions, _build_template_application_rule, + _build_segment_usage_guidance, +) +``` + +### B. Heuristic compose worker + +The `compose_worker` in `workers.py` uses a keyword-matching heuristic that +recognizes only 13 hardcoded words. The benchmark module already has a working +LLM compose pattern using `acall_llm_structured()`. + +## Approach + +### Phase 1a: Schema Provider System + +Create `imas_codex/sn/schema_providers.py` implementing the 3-tier caching +design from `plans/research/standard-names/06-schema-provider-design.md`. + +**Tier 1: Process-lifetime (static, ~15KB)** + +These import directly from `imas_standard_names` and are cached with +`@lru_cache(maxsize=1)`. They never change during a pipeline run. + +| Provider | Source | Content | +|----------|--------|---------| +| `grammar_context` | `grammar.constants.*`, `tools.grammar._build_*` | Canonical pattern, segment order, template rules, exclusivity pairs | +| `segment_descriptions` | `tools.grammar._get_segment_descriptions()` | Per-segment rich descriptions with critical distinctions | +| `segment_usage_guidance` | `tools.grammar._build_segment_usage_guidance()` | Usage patterns, example constructions per segment | +| `vocabulary_tokens` | `SEGMENT_TOKEN_MAP` | Token lists per segment with counts | +| `field_schema_guidance` | `FIELD_GUIDANCE`, `TYPE_SPECIFIC_REQUIREMENTS` | Per-field validation rules, common mistakes | + +**Tier 2: Catalog-lifetime (~4KB)** + +Loaded from the existing StandardName graph nodes or YAML catalog. +Invalidated on write. Cached per session. + +| Provider | Source | Content | +|----------|--------|---------| +| `existing_names_summary` | Graph query | Count, by-kind breakdown, sample names | +| `example_entries` | `resources/standard_name_examples/` (42 YAML files) | Curated high-quality entries for few-shot prompting | + +**Tier 3: Per-call dynamic (~3KB)** + +Assembled fresh for each LLM batch call. + +| Provider | Source | Content | +|----------|--------|---------| +| `dd_paths_context` | Extract worker output | Projected path info for the batch's IDS | +| `tokamak_parameters` | `resources/tokamak_parameters/*.yml` | Real machine dimensions for grounding doc examples | + +**Entry point:** +```python +async def get_schema_for_prompt( + schema_needs: list[str], + *, + dynamic_context: dict[str, Any] | None = None, +) -> dict[str, str]: + """Load requested providers and merge their output as prompt variables.""" +``` + +Each prompt template declares its `schema_needs` in frontmatter — only the +requested providers are loaded. This keeps prompt size predictable (~20KB). + +### Phase 1b: Rewrite compose prompt + +Rewrite `llm/prompts/sn/compose_dd.md` to consume the rich schema provider +output instead of bare enum lists. The prompt should reference: +- `{{ grammar_context }}` — canonical pattern, segment order, template rules +- `{{ segment_descriptions }}` — critical distinctions per segment +- `{{ vocabulary_tokens }}` — valid tokens per segment +- `{{ example_entries }}` — few-shot examples from the curated set +- `{{ dd_paths_context }}` — DD path info for the current batch +- `{{ tokamak_parameters }}` — machine parameters for grounding examples + +### Phase 1c: LLM compose worker + +Replace `_compose_single()` with batch LLM composition mirroring the +benchmark's `_run_model()` pattern: + +```python +async def compose_worker(state: SNBuildState, **_kwargs) -> None: + from imas_codex.discovery.base.llm import acall_llm_structured + from imas_codex.llm.prompt_loader import render_prompt + from imas_codex.sn.schema_providers import get_schema_for_prompt + from imas_codex.sn.models import SNComposeBatch + from imas_codex.settings import get_model + + model = get_model("language") + + # Load static + catalog context once + static_context = await get_schema_for_prompt([ + "grammar_context", "segment_descriptions", "vocabulary_tokens", + "field_schema_guidance", "example_entries", "existing_names_summary", + ]) + + # Group candidates by IDS for coherent batches + batches = _group_candidates_by_ids(state.candidates) + + composed = [] + for batch in batches: + # Load per-call context for this batch + dynamic_context = await get_schema_for_prompt( + ["dd_paths_context", "tokamak_parameters"], + dynamic_context={"ids_name": batch["group_key"], "items": batch["items"]}, + ) + prompt_context = {**static_context, **dynamic_context, **batch} + prompt_text = render_prompt("sn/compose_dd", prompt_context) + messages = [{"role": "user", "content": prompt_text}] + + result, cost, tokens = await acall_llm_structured( + model=model, messages=messages, response_model=SNComposeBatch, + ) + state.compose_stats.cost += cost + for c in result.candidates: + composed.append(c.model_dump()) + + state.composed = composed + state.compose_phase.mark_done() +``` + +## Files to Create/Modify + +### New: `imas_codex/sn/schema_providers.py` + +The schema provider system with 3-tier caching. + +### Modify: `imas_codex/llm/prompts/sn/compose_dd.md` + +Rewrite to consume rich schema provider variables instead of bare enums. +Add `schema_needs` frontmatter declaring required providers. + +### Modify: `imas_codex/sn/workers.py` + +- Delete `_compose_single()` and `_extract_physical_base()` (lines 172-247) +- Rewrite `compose_worker()` to use batch LLM calls with schema providers +- Rename `state.validated` → `state.composed` (see plan 10) + +### Modify: `imas_codex/sn/state.py` + +- Add `composed: list[dict]` field (rename from `validated`) + +### Modify: `imas_codex/sn/benchmark.py` + +- Replace `build_grammar_context()` with `get_schema_for_prompt()` call +- This ensures benchmark and pipeline use identical prompt context + +## Acceptance Criteria + +- Schema providers return ~15KB of static context (grammar, segments, vocabulary) +- `sn build --source dd --ids equilibrium` calls the LLM and produces real names +- Grammar round-trip validation passes for >80% of composed names +- Cost tracked in `state.compose_stats.cost` +- `--dry-run` still works (skips LLM, reports candidate count) +- Benchmark continues to work with the same schema providers + +## Testing + +- Unit test: schema providers return expected content structure and size +- Unit test: `@lru_cache` actually caches Tier 1 providers +- Integration: `sn build --source dd --ids equilibrium --dry-run` +- Integration: `sn build --source dd --ids equilibrium` (end-to-end) +- Integration: `sn benchmark --ids equilibrium --max-candidates 10` +- Quality: composed names parse via grammar round-trip + +## Tokamak Parameters for Documentation + +The 42 standard name example files in `resources/standard_name_examples/` +include `documentation` fields with LaTeX-formatted content referencing +real physical dimensions (e.g., "ITER major radius R₀ = 6.2 m"). The +tokamak parameters database (12 YAML files with sourced values) prevents +hallucinations in these documentation strings. + +For Phase 1, tokamak parameters are loaded as a per-call provider and +injected into the prompt alongside DD path context. The LLM uses real +machine parameters (major radius, minor radius, B_T, I_p, etc.) when +generating documentation examples instead of inventing plausible but +incorrect values. + +## Notes + +- The compose prompt already handles skipping metadata/index paths +- Existing names list prevents duplicates in the prompt +- `SNComposeBatch` model includes both `candidates` and `skipped` lists +- **Import backing functions directly** from `imas_standard_names` — never + call MCP tools from the pipeline +- The `_build_*` functions in `tools/grammar.py` are private but stable — + consider making them public or extracting their logic into `constants.py` diff --git a/plans/features/standard-names/09-sn-generate.md b/plans/features/standard-names/09-sn-generate.md new file mode 100644 index 000000000..78e7d31d2 --- /dev/null +++ b/plans/features/standard-names/09-sn-generate.md @@ -0,0 +1,634 @@ +# 09: Standard Name Generation Pipeline + +**Status:** Ready to implement +**Replaces:** Previous 09, 10, 11 (which are archived by this plan) +**Effort:** 4 phases, parallelizable + +## Executive Summary + +Replace the heuristic `_compose_single()` with LLM-backed composition using +rich prompt context from `imas-standard-names` backing functions. The pipeline +runs as: **EXTRACT → COMPOSE → VALIDATE → PERSIST** via `sn build`. + +Key design decisions (from rubber-duck critique): + +1. **Existing names are a reuse list, not a banned list** — many DD paths can + map to the same StandardName. Dedup by `source_id`, not `standard_name`. +2. **VALIDATE stays separate** — it is the quality gate. Reports `validate_valid`, + `validate_invalid`, `fields_consistent` as distinct metrics. +3. **Checkpoint/resume via source-level skip** — extract skips sources already + linked via `(:IMASNode)-[:HAS_STANDARD_NAME]->(sn)` unless `--force`. +4. **Explicit provenance** — persist model name, generation timestamp, + `review_status=skipped` (v1), never default confidence to 1.0. +5. **No UnitOfWork** — graph MERGE is idempotent. Resumability comes from + source-level skip, not undo stacks. +6. **Relationship direction: entity → concept** — `(:IMASNode)-[:HAS_STANDARD_NAME]->(sn:StandardName)` + not `(:StandardName)-[:DERIVED_FROM]->(src)`. Consistent with `MEASURES`. +7. **State fields are past tense** — `extracted`, `composed`, `reviewed`, `validated`. + +## Architecture + +``` +sn build --source dd --ids equilibrium --limit 200 + + EXTRACT (sync) COMPOSE (async LLM) VALIDATE (sync) PERSIST (sync) + ┌─────────────┐ ┌──────────────────┐ ┌───────────────┐ ┌──────────────────┐ + │ Query graph │ │ Render prompts │ │ Grammar parse │ │ MERGE StandardName│ + │ Skip named │ ──────▶ │ acall_structured │ ────▶ │ Fields check │ ────▶ │ HAS_STANDARD_NAME│ + │ Cluster batch│ │ Semaphore(5) │ │ Normalize │ │ Provenance props │ + └─────────────┘ └──────────────────┘ └───────────────┘ └──────────────────┘ + state.extracted state.composed state.validated graph writes +``` + +### State Field Naming (all past tense) + +| Field | Written by | Read by | +|-------|-----------|---------| +| `extracted` | extract | compose | +| `composed` | compose | review or validate | +| `reviewed` | review | validate | +| `validated` | validate | persist | + +### Relationship Direction: IMASNode → StandardName + +The StandardName is the canonical physics concept hub. Entities point TO it: + +``` +(:IMASNode)-[:HAS_STANDARD_NAME]->(sn:StandardName) +(:FacilitySignal)-[:MEASURES]->(sn:StandardName) +``` + +**NOT** `(:StandardName)-[:DERIVED_FROM]->(src)`. Reasons: + +1. **StandardName is the anchor, not the dependent.** "electron_temperature" is + a canonical concept that multiple DD paths and signals reference. DERIVED_FROM + puts the name as the dependent entity, which is semantically backwards. +2. **Consistent with FacilitySignal pattern.** Signals already use + `MEASURES -> StandardName`. DD paths should follow the same entity-to-concept + direction: `HAS_STANDARD_NAME -> StandardName`. +3. **Natural traversal.** The most common query is "what standard name for this + DD path?" — `(node)-[:HAS_STANDARD_NAME]->(sn)` is direct. +4. **Provenance is properties, not relationships.** How the name was generated + (model, source_type, generated_at, confidence) are properties on StandardName, + not a separate relationship. + +This means `write_standard_names()` in `graph_ops.py` changes from: +```cypher +MERGE (sn)-[:DERIVED_FROM]->(src) -- OLD: wrong direction +``` +to: +```cypher +MERGE (src)-[:HAS_STANDARD_NAME]->(sn) -- NEW: entity points to concept +``` + +### Prompt Design + +**System prompt** (`sn/compose_system.md`) — static, prompt-cached (~15KB): +- Grammar rules, segment descriptions, critical distinctions +- Vocabulary tokens with descriptions (not bare enums) +- Field guidance and requirements +- Curated examples grouped by pattern (~42 examples) +- Tokamak parameter ranges for grounding documentation + +**User prompt** (`sn/compose_dd.md`) — dynamic per-batch (~3KB): +- IDS name, cluster context +- DD paths with descriptions, units, data types +- Existing standard names to reuse (not avoid) + +The system prompt content comes from `imas_standard_names` backing functions: +- `grammar.constants.SEGMENT_RULES` → segment descriptions + critical distinctions +- `grammar.constants.SEGMENT_TOKEN_MAP` → vocabulary with descriptions +- `grammar.constants.SEGMENT_TEMPLATES` → template application rules +- `grammar.field_schemas.FIELD_GUIDANCE` → per-field requirements +- `tools.grammar._build_canonical_pattern()` → composition pattern +- `resources/tokamak_parameters/` → grounding data +- `resources/standard_name_examples/` → curated examples + +These are assembled into Jinja include files at build time (cached in-process). + +### Batching Strategy + +Hierarchical grouping for semantic coherence: + +1. **Primary:** Semantic cluster (`IMASSemanticCluster` via `IN_CLUSTER`) +2. **Secondary:** IDS name (for unclustered paths or large clusters) +3. **Tertiary:** Path prefix depth 3 (for groups > 25) +4. **Cap:** 25 paths per batch + +``` +equilibrium paths (1583) + ├── Cluster: "Radial Profile Coordinates" (12 paths) → 1 batch + ├── Cluster: "Plasma Boundary Geometry" (39 paths) → 2 batches + ├── Unclustered (1383 paths) + │ ├── time_slice/profiles_1d (52) → 3 batches + │ ├── time_slice/boundary (39) → 2 batches + │ └── ... +``` + +### Existing Names = Reuse List + +The graph model supports many-to-one: multiple DD paths can HAS_STANDARD_NAME +the same StandardName. The LLM prompt should say: + +> These standard names already exist. **Reuse** them when the DD path measures +> the same quantity. Only create a new name when no existing name fits. + +Deduplication is by `source_id` (DD path), not `standard_name`. + +### Repo Architecture (future direction) + +With graph-backed generation, the repos have clear responsibilities: + +| Repo | Role | Contains | +|------|------|----------| +| **imas-standard-names** | Grammar library + web docs | Grammar rules, enums, compose/parse, validation, Quarto rendering, tokamak params, examples | +| **imas-standard-names-catalog** | Export target | YAML files exported from graph via `sn publish` | +| **imas-codex** | Generation platform | LLM pipeline, graph storage, MCP tools, semantic search | + +**imas-standard-names refactoring (Phase 1 prerequisite):** + +The MCP server backing functions contain rich prompt context builders that are +currently private (`_build_canonical_pattern()`, `_get_segment_descriptions()`). +These should be made **public API** in the grammar library so imas-codex can +import them cleanly. Specifically: + +1. Make `tools.grammar._build_*()` functions public (drop underscore prefix) +2. Move them from `tools/` to `grammar/context.py` (they're grammar knowledge, + not MCP tool internals) +3. Remove MCP server code from imas-standard-names (future — after pipeline proves out) + +The generation capability (UnitOfWork, catalog CRUD, MCP tools) is migrating to +imas-codex. The grammar library retains: rules definition, validation, resources, +web rendering. + +--- + +## Phase 1: Prompt Templates + +**Agent:** engineer (3 files, well-specified) +**Depends on:** nothing + +Create the Jinja include files that assemble rich grammar context from +`imas_standard_names` backing functions. + +### Files to Create + +**`imas_codex/sn/context.py`** — Grammar context builder (replaces `build_grammar_context()`): + +```python +"""Rich grammar context for SN compose prompts. + +Imports segment rules, vocabulary, field guidance, and examples from +imas_standard_names backing functions. Assembles them into template +variables for Jinja2 rendering. + +Caches assembled context in-process (same pattern as wiki calibration). +""" + +def build_compose_context() -> dict[str, Any]: + """Build rich context dict for sn/compose_system.md template. + + Returns keys: grammar_rules, vocabulary, field_guidance, examples, + tokamak_ranges, segment_descriptions, critical_distinctions, + template_rules, plus the original enum lists for the user prompt. + """ + ... +``` + +Internals: +- Import `SEGMENT_RULES`, `SEGMENT_ORDER`, `SEGMENT_TEMPLATES`, + `SEGMENT_TOKEN_MAP` from `imas_standard_names.grammar.constants` +- Import `FIELD_GUIDANCE`, `TYPE_SPECIFIC_REQUIREMENTS` from + `imas_standard_names.grammar.field_schemas` +- Import `_build_canonical_pattern()`, `_get_segment_descriptions()`, + `_build_segment_usage_guidance()` from `imas_standard_names.tools.grammar` + (make public wrappers if needed) +- Load tokamak parameters from `imas_standard_names/resources/tokamak_parameters/` +- Load curated examples from `imas_standard_names/resources/standard_name_examples/` +- Cache result in module-level dict with TTL (same pattern as `_wiki_calibration_cache`) + +**`imas_codex/llm/prompts/sn/compose_system.md`** — System prompt template: + +```markdown +--- +name: sn/compose_system +description: Static system prompt for SN composition (prompt-cached) +task: composition +dynamic: false +--- + +You are a physics nomenclature expert generating standard names ... + +## Grammar Rules + +{{ grammar_rules }} + +## Vocabulary + +{{ vocabulary }} + +## Segment Descriptions and Critical Distinctions + +{{ segment_descriptions }} +{{ critical_distinctions }} + +## Field Guidance + +{{ field_guidance }} + +## Composition Pattern + +{{ canonical_pattern }} + +## Examples + +{{ examples }} + +## Output Format +... +``` + +**`imas_codex/llm/prompts/sn/compose_dd.md`** — Rewrite user prompt (dynamic): + +Strip the grammar rules (now in system prompt). Keep only: +- IDS name, cluster context +- DD paths with descriptions, units, data types +- Existing standard names (as reuse list, not banned list) +- Output format specification + +### Acceptance Criteria + +- `build_compose_context()` returns a dict with all required keys +- System prompt renders to ~15KB of structured grammar context +- User prompt renders to ~3KB of per-batch data +- Grammar context includes segment descriptions (not bare enum lists) +- Tokamak parameters are loaded and available for documentation grounding +- At least 40 curated examples are included + +### Tests + +- Unit test: `build_compose_context()` returns expected keys +- Unit test: System prompt renders without errors +- Unit test: User prompt renders with sample batch data + +--- + +## Phase 2: Extract + Batching Improvements + +**Agent:** engineer (2-3 files, well-specified) +**Depends on:** nothing (parallel with Phase 1) + +### 2a: Source-level skip (resumability) + +In `graph_ops.py`, add a function to get already-named source IDs: + +```python +def get_named_source_ids() -> set[str]: + """Return source_ids already linked via HAS_STANDARD_NAME.""" + with GraphClient() as gc: + results = gc.query(""" + MATCH (src)-[:HAS_STANDARD_NAME]->(sn:StandardName) + RETURN DISTINCT src.id AS source_id + """) + return {r["source_id"] for r in results} +``` + +In `extract_worker`, skip already-named sources: + +```python +named = get_named_source_ids() +raw = [c for c in raw if c.get("path", c.get("signal_id")) not in named] +wlog.info("Skipped %d already-named sources", len(named & ...)) +``` + +Add `--force` flag to `sn build` CLI to bypass skip logic. + +### 2b: Cluster-based batching + +In `sources/dd.py`, replace flat IDS grouping with hierarchical batching: + +```python +def _build_semantic_batches(results: list[dict], cap: int = 25) -> list[ExtractionBatch]: + """Group paths by cluster → IDS → prefix for LLM-coherent batches.""" + # 1. Separate clustered vs unclustered + # 2. Group clustered paths by cluster_id, sub-group by IDS + # 3. Group unclustered paths by IDS, sub-group by prefix depth 3 + # 4. Split any group > cap into sub-batches +``` + +### 2c: State field rename + +In `state.py`, rename all state fields to past tense: + +| Old | New | Written by | Read by | +|-----|-----|-----------|---------| +| `candidates` | `extracted` | extract | compose | +| `validated` (used as compose output) | `composed` | compose | review or validate | +| `reviewed` | `reviewed` | review | validate | +| (new) | `validated` | validate | persist | + +Update all references in `workers.py`, `pipeline.py`, `progress.py`. + +### Acceptance Criteria + +- `sn build` second run on same IDS skips already-derived names +- `sn build --force` re-processes all paths +- Batches are ≤25 paths, grouped by cluster/IDS/prefix +- State fields follow pipeline phase naming + +### Tests + +- Unit test: `get_derived_source_ids()` returns expected IDs +- Unit test: `_build_semantic_batches()` respects cap, groups by cluster +- Unit test: State field wiring (compose writes composed, validate writes validated) + +--- + +## Phase 3: LLM Compose Worker + +**Agent:** architect (5+ files, async LLM, prompt rendering) +**Depends on:** Phase 1 (prompt templates), Phase 2 (batching + state) + +Replace `_compose_single()` heuristic with batch LLM calls. + +### Core Changes + +**`workers.py` — `compose_worker`:** + +```python +async def compose_worker(state: SNBuildState, **_kwargs) -> None: + """LLM-generate standard names from extracted batches. + + Uses acall_llm_structured() with system/user prompt split + for prompt caching. Runs batches concurrently with semaphore. + """ + from imas_codex.discovery.base.llm import acall_llm_structured + from imas_codex.llm.prompt_loader import render_prompt + from imas_codex.settings import get_model + from imas_codex.sn.context import build_compose_context + + model = get_model("language") + context = build_compose_context() + + # Render system prompt once (cached via prompt caching) + system_prompt = render_prompt("sn/compose_system", context) + + # Process batches concurrently + sem = asyncio.Semaphore(5) + tasks = [] + for batch in state.extracted: # ExtractionBatch objects + tasks.append(_compose_batch(batch, model, system_prompt, context, sem, state)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Collect successful results + composed = [] + for r in results: + if isinstance(r, list): + composed.extend(r) + elif isinstance(r, Exception): + state.compose_stats.errors += 1 + + state.composed = composed +``` + +**`workers.py` — `_compose_batch`:** + +```python +async def _compose_batch( + batch: ExtractionBatch, + model: str, + system_prompt: str, + context: dict, + sem: asyncio.Semaphore, + state: SNBuildState, +) -> list[dict]: + """Compose standard names for a single batch via LLM.""" + async with sem: + user_context = { + "items": batch.items, + "ids_name": batch.group_key, + "existing_names": sorted(batch.existing_names)[:200], + "cluster_context": batch.context, + } + user_prompt = render_prompt("sn/compose_dd", {**context, **user_context}) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + result, cost, tokens = await acall_llm_structured( + model=model, + messages=messages, + response_model=SNComposeBatch, + ) + + state.compose_stats.cost += cost + state.compose_stats.total_tokens += tokens + state.compose_stats.record_batch(len(batch.items)) + + return [c.model_dump() for c in result.candidates] +``` + +### Key Design Notes + +- **System/user split**: System prompt is ~15KB static content. With + OpenRouter prompt caching, this is cached after the first call, reducing + latency and cost for subsequent batches. +- **Semaphore(5)**: Limits concurrent LLM calls. Matches wiki worker pattern. +- **ExtractionBatch objects**: compose_worker receives `state.candidates` as + a list of ExtractionBatch (from Phase 2 batching). Each batch has items, + group_key, context, existing_names. +- **Cost tracking**: Per-batch cost accumulated on `state.compose_stats`. + +### Validate Worker Update + +Add fields-consistency check (from benchmark's `validate_candidate()`): + +```python +# In validate_worker, after grammar round-trip: +from imas_standard_names.grammar import StandardName, compose_standard_name + +fields = entry.get("fields", {}) +if fields: + sn = StandardName(**_parse_fields(fields)) + from_fields = compose_standard_name(sn) + if from_fields != normalized: + entry["fields_consistent"] = False + else: + entry["fields_consistent"] = True +``` + +Report distinct metrics: +- `validate_valid` — name passes grammar round-trip +- `validate_invalid` — name fails grammar parse +- `validate_fields_consistent` — fields compose to same name +- `validate_fields_inconsistent` — fields compose to different name + +### Persist Worker (new) + +Add a PERSIST phase after VALIDATE: + +```python +async def persist_worker(state: SNBuildState, **_kwargs) -> None: + """Write validated standard names to graph with provenance.""" + if state.dry_run: + state.persist_phase.mark_done() + return + + from imas_codex.settings import get_model + from imas_codex.sn.graph_ops import write_standard_names + + # Enrich with provenance before writing + model = get_model("language") + for entry in state.validated: + entry.setdefault("model", model) + entry.setdefault("review_status", "skipped") # v1: no review + entry.setdefault("generated_at", datetime.now(UTC).isoformat()) + # confidence comes from LLM output — never default to 1.0 + + written = write_standard_names(state.validated) + state.stats["persist_written"] = written + state.persist_phase.mark_done() +``` + +`write_standard_names()` must be updated to create `HAS_STANDARD_NAME` +relationships (entity → concept) instead of `DERIVED_FROM` (concept → entity): + +```cypher +-- NEW: entity points to concept +UNWIND $batch AS b +MERGE (sn:StandardName {id: b.id}) +SET sn += b.properties +WITH sn, b +MATCH (src:IMASNode {id: b.source_id}) +MERGE (src)-[:HAS_STANDARD_NAME]->(sn) +``` + +### Pipeline Wiring + +Update `pipeline.py` to wire 4 phases: + +```python +workers = [ + WorkerSpec("extract", "extract_phase", extract_worker), + WorkerSpec("compose", "compose_phase", compose_worker, depends_on=["extract_phase"]), + WorkerSpec("validate", "validate_phase", validate_worker, depends_on=["compose_phase"]), + WorkerSpec("persist", "persist_phase", persist_worker, depends_on=["validate_phase"]), +] +``` + +Review phase is removed from default pipeline. Can be re-added later as an +optional phase between compose and validate. + +### Schema Update + +Add provenance fields and `HAS_STANDARD_NAME` relationship to `facility.yaml`: + +```yaml +StandardName: + attributes: + # ... existing fields ... + model: + description: LLM model that generated this name + review_status: + description: Review lifecycle status + range: StandardNameReviewStatus # new enum: skipped, pending, reviewed + generated_at: + description: When this name was generated + range: datetime + +# In IMASNode (imas_dd.yaml) or a new relationship class: +# IMASNode needs a slot to express the HAS_STANDARD_NAME relationship. +# This follows the dual property + relationship model. +``` + +**Relationship declaration:** Add `has_standard_name` slot to IMASNode in +`imas_dd.yaml` (or equivalent): + +```yaml +has_standard_name: + description: Standard name for this DD path + range: StandardName + annotations: + relationship_type: HAS_STANDARD_NAME +``` + +This creates both a property (`n.has_standard_name`) and a relationship +`(:IMASNode)-[:HAS_STANDARD_NAME]->(sn:StandardName)` via `create_nodes()`. + +> **Note:** Since StandardName is in `facility.yaml` and IMASNode is in +> `imas_dd.yaml`, the cross-schema reference needs verification during +> Phase 3 implementation. The MERGE in `write_standard_names()` can create +> the relationship directly without the slot if cross-schema refs are complex. + +### Acceptance Criteria + +- `sn build --source dd --ids equilibrium --limit 50` produces StandardName + nodes in the graph via LLM composition +- System prompt is rendered once, user prompt per batch +- Validate reports `validate_valid`, `validate_invalid`, `fields_consistent` +- Persist writes provenance (model, review_status, generated_at, confidence) +- Cost tracking works across batches +- `--dry-run` skips LLM calls and graph writes + +### Tests + +- Integration test: compose_worker with mocked LLM produces valid candidates +- Unit test: validate_worker reports fields_consistent metric +- Unit test: persist_worker enriches entries with provenance +- Unit test: pipeline wires 4 phases with correct dependencies + +--- + +## Phase 4: End-to-End Verification + +**Agent:** architect (testing + prompt tuning) +**Depends on:** Phases 1-3 + +### Steps + +1. Run: `sn build --source dd --ids equilibrium --limit 50 --dry-run` + - Verify: extraction count, batch sizes, no LLM calls +2. Run: `sn build --source dd --ids equilibrium --limit 50` + - Verify: StandardName nodes created in graph + - Verify: DERIVED_FROM relationships exist + - Verify: provenance fields populated + - Check: grammar validity rate (target > 80%) + - Check: fields consistency rate (target > 70%) +3. Run again (same command): + - Verify: source-level skip works (fewer new candidates) +4. Run: `sn build --source dd --ids equilibrium --limit 50 --force` + - Verify: all paths re-processed +5. Inspect prompt quality: + - Review system prompt size and content + - Check prompt cache hit rate in logs + - Compare composed names against benchmark reference set + +### Acceptance Criteria + +- End-to-end pipeline produces valid StandardName nodes +- Grammar validity rate > 80% on equilibrium paths +- Source-level skip prevents duplicate LLM spend on re-runs +- Cost tracking accurate within 5% of actual API cost + +--- + +## Documentation Updates + +After all phases complete: + +| Target | Update | +|--------|--------| +| `AGENTS.md` | Document `sn build` pipeline architecture | +| `plans/features/standard-names/00-implementation-order.md` | Update to reflect new plan structure | +| `plans/README.md` | Update plan status | + +## Archived Plans + +Plans 09 (previous), 10, 11 are superseded by this consolidated plan. +The content from those plans is incorporated here: +- Plan 10 bug fixes → Phase 2 (source skip, state rename, batch improvement) +- Plan 11 publish validation → deferred until Phase 4 evaluation reveals needs +- Plan 09 schema providers → Phase 1 (context builder) diff --git a/plans/features/standard-names/10-pipeline-fixes.md b/plans/features/standard-names/10-pipeline-fixes.md new file mode 100644 index 000000000..2296a9086 --- /dev/null +++ b/plans/features/standard-names/10-pipeline-fixes.md @@ -0,0 +1,79 @@ +# 10: Pipeline Fixes + +**Status:** Pending +**Priority:** High — prevents wasted LLM budget +**Depends on:** 09 (LLM compose working) +**Effort:** 2-4 hours + +## Problem + +Two bugs in the current pipeline: + +### Bug 1: Extract dedup is a no-op + +`extract_worker` (workers.py:68-75) fetches existing standard names from the +graph but never uses them to filter candidates: + +```python +existing = get_existing_standard_names() # Fetched... +wlog.info("Found %d raw candidates, %d existing names", len(raw), len(existing)) +return raw # ...but never filtered! +``` + +Every `sn build` run will re-compose names that already exist in the graph, +wasting LLM budget. + +### Bug 2: Confusing field naming + +`compose_worker` stores its output in `state.validated` (line 158), which +is then read by the VALIDATE worker. But `validated` implies post-validation +data. This will confuse anyone reading the code. + +The state fields should follow the pipeline: `candidates → composed → reviewed → validated`. + +## Tasks + +### 1. Fix extract dedup + +In `extract_worker`, filter candidates against existing names: + +```python +existing = get_existing_standard_names() +existing_sources = {n.get("derived_from_dd") or n.get("derived_from_signal") + for n in existing if n.get("derived_from_dd") or n.get("derived_from_signal")} +filtered = [c for c in raw if c.get("path", c.get("signal_id")) not in existing_sources] +``` + +### 2. Rename state fields + +In `state.py`, rename for clarity: + +| Old | New | Written by | Read by | +|-----|-----|-----------|---------| +| `validated` (used as compose output) | `composed` | compose_worker | review_worker | +| `reviewed` | `reviewed` | review_worker | validate_worker | +| (new) | `validated` | validate_worker | (terminal / publish) | + +Update all references in `workers.py`. + +### 3. Review caps existing names smartly + +`_build_review_context()` (workers.py:439) caps existing names at 200: +```python +"existing_names": sorted(existing_names)[:200] +``` + +Instead, filter to names from the same IDS/domain as the current batch, +then cap at 200. This gives better dedup context. + +## Acceptance Criteria + +- Running `sn build` twice on the same IDS skips already-composed names on the second run +- State field names match the pipeline phase that writes them +- Review context includes domain-relevant existing names (not random 200) + +## Testing + +- Run `sn build --source dd --ids equilibrium` twice +- Second run should have fewer candidates (already-existing names filtered out) +- All tests pass with renamed state fields diff --git a/plans/features/standard-names/11-publish-validation.md b/plans/features/standard-names/11-publish-validation.md new file mode 100644 index 000000000..7c76e8f65 --- /dev/null +++ b/plans/features/standard-names/11-publish-validation.md @@ -0,0 +1,188 @@ +# 11: Publish Validation + Graph-Backed Staging + +**Status:** Pending +**Priority:** High — prevents publishing invalid entries to catalog +**Depends on:** 09 (schema providers + LLM compose), 10 (pipeline fixes) +**Effort:** 1-2 days + +## Problem + +Two issues with the current write/publish flow: + +### A. No validation gate + +The `sn publish` command converts StandardName graph nodes to YAML catalog +files, but only checks for filename collisions. It does NOT validate that +the generated entries conform to the catalog's grammar, schema, or semantic rules. + +### B. No staging or rollback + +The pipeline holds results in plain Python lists (`state.candidates`, +`state.composed`) with no transactional semantics. If the compose worker +crashes midway through a batch, partial results are lost. There's no +"validate-before-commit" gate — names go straight from the LLM to the +graph via `write_standard_names()`. + +The `imas-standard-names` UnitOfWork pattern provides exactly this: +- In-memory staging with add/update/remove/rename operations +- Undo stack with typed operations (UndoOpAdd, UndoOpDelete, etc.) +- Validation gate before commit +- Rollback on failure + +## Approach + +### Phase 4a: Validation gate for publish + +Import validation functions from `imas_standard_names` and run them +against generated entries before writing YAML. + +```python +from imas_standard_names.grammar import compose_standard_name, parse_standard_name +from imas_standard_names.grammar.field_schemas import FIELD_GUIDANCE +from imas_standard_names.services import validate_models +``` + +### Phase 4b: Graph-backed staging + +Adapt the UnitOfWork pattern for graph-backed persistence. The key insight: + +**The YAML UnitOfWork commits by writing YAML files. The graph UnitOfWork +commits by writing to Neo4j.** The staging, validation, undo, and rollback +mechanics are identical — only the persistence backend changes. + +```python +class GraphUnitOfWork: + """In-memory staging with graph persistence commit boundary.""" + + def __init__(self): + self._staged: dict[str, StandardNameEntry] = {} + self._undo: list[UndoOp] = [] + + def add(self, entry: StandardNameEntry) -> None: + if entry.name in self._staged: + raise ValueError(f"'{entry.name}' already staged") + self._staged[entry.name] = entry + self._undo.append(UndoOpAdd(entry.name)) + + def validate(self) -> list[str]: + """Run all validation checks. Returns list of error strings.""" + errors = [] + for entry in self._staged.values(): + # Grammar round-trip + errors.extend(self._check_grammar(entry)) + # Schema conformance + errors.extend(self._check_schema(entry)) + # Cross-entry semantic checks + errors.extend(self._check_semantic_conflicts()) + return errors + + def commit(self) -> int: + """Validate and write all staged entries to graph. + + Returns the number of entries written. Raises ValueError if + validation fails (entries remain staged for correction). + """ + issues = self.validate() + if issues: + raise ValueError("Validation failed:\n" + "\n".join(issues)) + written = write_standard_names([e.model_dump() for e in self._staged.values()]) + self._staged.clear() + self._undo.clear() + return written + + def rollback(self) -> None: + """Discard all staged entries.""" + self._staged.clear() + self._undo.clear() +``` + +The compose worker stages results instead of accumulating in plain lists: + +```python +# Before (no staging): +state.composed.append(result.model_dump()) + +# After (staged): +state.staging.add(StandardNameEntry(**result.model_dump())) +``` + +The validate worker calls `state.staging.validate()` and only commits +entries that pass all checks. + +## Files to Create/Modify + +### New: `imas_codex/sn/staging.py` + +Graph-backed UnitOfWork with: +- `add()`, `update()`, `remove()` with undo stack +- `validate()` importing `imas_standard_names` validation +- `commit()` calling `write_standard_names()` +- `rollback()` discarding staged entries + +### Modify: `imas_codex/sn/publish.py` + +Add validation step between entry generation and YAML writing: + +```python +for entry in entries: + # Grammar round-trip + parsed = parse_standard_name(entry.name) + recomposed = parsed.compose() + if recomposed != entry.name: + report_warning(f"Round-trip mismatch: {entry.name} → {recomposed}") + + # Schema validation + try: + create_standard_name_entry(entry.to_dict()) + except ValidationError as e: + report_error(f"Schema-invalid: {entry.name}: {e}") +``` + +### Modify: `imas_codex/sn/state.py` + +Add `staging: GraphUnitOfWork` field, replacing plain list accumulation. + +### Modify: `imas_codex/sn/workers.py` + +- Compose worker stages results via `state.staging.add()` +- Validate worker calls `state.staging.validate()` +- Final write uses `state.staging.commit()` + +## Acceptance Criteria + +- `sn publish --dry-run` shows validation summary: + ``` + Publish Validation: + Grammar: 42/45 passed (3 failed) + Schema: 41/45 passed (4 failed) + Publishable: 40 entries + ``` +- Grammar-invalid names are excluded from output +- Schema-invalid entries are excluded from output +- Compose worker uses staging instead of plain lists +- If compose worker crashes mid-batch, `rollback()` clears partial results +- `--force` flag overrides warnings (but not errors) + +## Testing + +- Unit test: GraphUnitOfWork add/validate/commit/rollback cycle +- Unit test: validation catches invalid grammar, missing fields, bad tags +- Unit test: undo stack correctly reverts operations +- Integration: `sn publish --dry-run` with known-bad entries +- Integration: `sn build` with crash simulation (verify rollback) + +## Design Decision: UnitOfWork vs Plain Lists + +**Why keep UnitOfWork even with Neo4j?** + +Neo4j has ACID transactions, but that only covers the *write* boundary. +The UnitOfWork pattern adds value at the *staging* boundary: + +1. **Validate-before-commit** — catch grammar/schema errors BEFORE touching + the graph, not after. Failed validation doesn't create orphan nodes. +2. **Batch rollback** — if the LLM produces garbage for one IDS batch, + discard just that batch without affecting earlier successes. +3. **Undo stack** — the review worker can flag entries for removal; + undo preserves the original for debugging. +4. **Clean separation** — "proposed" entries live in staging until explicitly + committed. The graph only contains validated, committed names. diff --git a/plans/features/standard-names/11-rich-compose.md b/plans/features/standard-names/11-rich-compose.md new file mode 100644 index 000000000..c8dcd318e --- /dev/null +++ b/plans/features/standard-names/11-rich-compose.md @@ -0,0 +1,286 @@ +# 11: Rich Standard Name Composition + +**Status:** Ready to implement +**Depends on:** 09 (pipeline working — DONE) +**Enables:** 12 (catalog import), 13 (publish pipeline) +**Agent:** architect (cross-module: schema + prompts + models + workers + graph_ops + tests) + +## Problem + +The compose worker generates bare StandardName nodes with only `id`, `physical_base`, +and `confidence`. The catalog schema requires ~12 rich fields: description, documentation +(LaTeX equations, inline cross-references, typical tokamak values, measurement methods), +unit, kind (scalar/vector/metadata), tags, links, ids_paths, status, validity_domain, +constraints, and grammar decomposition. + +The current `SNCandidate` Pydantic model captures only `source_id`, `standard_name`, +`fields`, `confidence`, and `reason`. The graph schema (`StandardName` in standard_name.yaml) +has `description`, `canonical_units`, `source`, `source_path`, `confidence` — but no +documentation, kind, tags, links, ids_paths, or grammar fields. + +Additional bugs found via audit: +- `write_standard_names()` uses unconditional SET (not coalesce) — re-runs erase data +- Schema doc says `(FacilitySignal)-[:MEASURES]->(StandardName)` but code uses + `HAS_STANDARD_NAME` for both entity types. `sources/signals.py` queries MEASURES + which is never written, so signal dedup is broken (silent re-processing). +- `review_status` enum has `candidate` (not past tense). User requires all past tense. +- Embedding generation not wired — persist worker never calls embed functions. +- Zero tests for `sn/graph_ops.py` — all DB operations untested. + +## Approach + +Split into two deployment units for parallelism: +- **Wave 1 (Phases 1+4):** Schema extension + persist fix + tests. Unlocks Plans 12/13. +- **Wave 2 (Phases 2+3+5):** LLM compose upgrade. Can run parallel with Plan 12. + +## Phase 1: Extend graph schema + fix consistency + +**Files:** `imas_codex/schemas/standard_name.yaml`, `imas_codex/sn/sources/signals.py` + +### 1a. Add rich fields to StandardName + +```yaml +documentation: + description: >- + Rich documentation with LaTeX equations, governing physics, + measurement methods, typical values, sign conventions. + Uses [name](#name) inline links to other standard names. +kind: + description: Entry kind — scalar, vector, or metadata + range: StandardNameKind +tags: + description: Classification tags from controlled vocabulary + multivalued: true + range: string +links: + description: Internal cross-references to related standard names (name only) + multivalued: true + range: string +ids_paths: + description: IMAS DD paths mapped to this standard name + multivalued: true + range: string +validity_domain: + description: Physical region where this quantity is defined +constraints: + description: Physical/mathematical constraints (e.g., T_e > 0) + multivalued: true + range: string +subject: + description: Particle species (electron, ion, deuterium, etc.) +component: + description: Vector component (radial, toroidal, vertical, etc.) +coordinate: + description: Coordinate qualifier +position: + description: Spatial location qualifier (magnetic_axis, midplane, etc.) +process: + description: Physical process qualifier (ohmic, bootstrap, etc.) +``` + +### 1b. Add StandardNameKind enum + +```yaml +StandardNameKind: + permissible_values: + scalar: { description: Scalar quantity } + vector: { description: Vector quantity (R,Z or multi-component) } + metadata: { description: Non-measurable concept or classification } +``` + +### 1c. Fix StandardNameReviewStatus enum (all past tense) + +Replace current `candidate/accepted/rejected/skipped` with: + +```yaml +StandardNameReviewStatus: + permissible_values: + drafted: { description: LLM-generated, awaiting review } + published: { description: Exported to catalog PR for review } + accepted: { description: Imported from merged catalog entry } + rejected: { description: Reviewed and rejected } + skipped: { description: Skipped during review (e.g., low confidence) } +``` + +### 1d. Fix schema doc — HAS_STANDARD_NAME for all entity types + +In `StandardName` class description, change: +``` +- (FacilitySignal)-[:MEASURES]->(StandardName) +``` +to: +``` +- (FacilitySignal)-[:HAS_STANDARD_NAME]->(StandardName) +``` + +### 1e. Fix signals.py MEASURES query + +In `sn/sources/signals.py`, change the MEASURES query to HAS_STANDARD_NAME +so signal dedup works correctly (reads must match writes). + +Run `uv run build-models --force` after schema changes. + +**Acceptance:** +- `uv run pytest tests/graph/test_schema_compliance.py` passes +- `grep -r MEASURES imas_codex/sn/` returns zero matches + +## Phase 2: Extend LLM response model + +**Files:** `imas_codex/sn/models.py` + +Extend `SNCandidate` with rich fields matching the catalog schema: + +```python +class SNCandidate(BaseModel): + """Full standard name entry from LLM composition.""" + + source_id: str = Field(description="Source entity ID (DD path or signal ID)") + standard_name: str = Field(description="Composed standard name in snake_case") + description: str = Field(description="One sentence, <120 chars") + documentation: str = Field(description="Rich docs with LaTeX, links, typical values") + unit: str | None = Field(default=None, description="SI unit string (eV, m, A, etc.)") + kind: Literal["scalar", "vector", "metadata"] = Field(description="Entry kind") + tags: list[str] = Field(default_factory=list, description="Classification tags") + links: list[str] = Field(default_factory=list, description="Related standard names") + ids_paths: list[str] = Field(default_factory=list, description="Mapped IMAS DD paths") + fields: dict[str, str] = Field(description="Grammar fields used") + confidence: float = Field(ge=0, le=1, description="Naming confidence") + reason: str = Field(description="Brief justification") + validity_domain: str | None = Field(default=None, description="Physical region") + constraints: list[str] = Field(default_factory=list, description="Physical constraints") +``` + +Update `compose_worker` in `workers.py` to pass all new fields through to +`state.composed` dicts (currently only passes id, source_type, source_id, +fields, confidence, reason — lines 193-202). + +**Acceptance:** Pydantic model validates against sample LLM output. + +## Phase 3: Update prompts for rich generation + +**Files:** +- `imas_codex/llm/prompts/sn/compose_system.md` — add output format section +- `imas_codex/llm/prompts/sn/compose_dd.md` — update expected output + +Add to system prompt: + +1. **Output format specification** — JSON schema for rich entries with all fields +2. **Documentation template** — opening paragraph, governing equations, physical + significance, measurement methods, typical values (cite specific machines from + tokamak_parameters), sign conventions, inline `[name](#name)` cross-references +3. **Tags guidance** — controlled vocabulary from catalog (primary: physics domain, + secondary: characteristics like spatial-profile, time-dependent, derived) +4. **Kind classification rules** — scalar (single value per point), vector (R,Z or + components), metadata (concepts, techniques, not measurable quantities) +5. **Links guidance** — only reference other standard names, 4-8 per entry, + validate existence against existing names list + +The compose_dd.md user prompt already passes existing names — extend to include +existing names with their descriptions for cross-referencing context. + +**Acceptance:** Dry-run `sn build --ids equilibrium --limit 5 --dry-run` shows +rich entries in progress output. + +## Phase 4: Fix persist, graph_ops, and wire embedding + +**Files:** +- `imas_codex/sn/workers.py` (persist_worker) +- `imas_codex/sn/graph_ops.py` (write_standard_names) + +### 4a. Fix unconditional SET overwrite bug + +Current code does `SET sn.description = b.description` which nulls out existing +data on re-runs. **This is a data corruption risk** — if Plan 12 imports 309 +catalog entries as `accepted`, any subsequent `sn build` on overlapping names +will downgrade them to `drafted` and erase catalog-authoritative fields. + +Fix with coalesce for ALL fields: + +```cypher +SET sn.description = coalesce(b.description, sn.description), + sn.documentation = coalesce(b.documentation, sn.documentation), + sn.kind = coalesce(b.kind, sn.kind), + sn.tags = coalesce(b.tags, sn.tags), + ... +``` + +### 4b. Write all rich fields + +Extend `write_standard_names()` to persist: description, documentation, kind, +tags, links, ids_paths, validity_domain, constraints, all grammar fields, +model, generated_at, review_status='drafted'. + +### 4c. Link CANONICAL_UNITS (HAS_UNIT) + +The schema defines `canonical_units` with `range: Unit`. By schema convention +this creates a `CANONICAL_UNITS` relationship. After MERGE StandardName: + +```cypher +WITH sn, b +WHERE b.unit IS NOT NULL +MERGE (u:Unit {id: b.unit}) +MERGE (sn)-[:CANONICAL_UNITS]->(u) +``` + +### 4d. Wire embedding generation + +The persist worker must call `embed_descriptions_batch()` from +`imas_codex/embeddings/description.py` after writing StandardName nodes. +Without this, Plan 14's MCP vector search returns zero results. + +```python +# After write_standard_names() +from imas_codex.embeddings.description import embed_descriptions_batch +embed_descriptions_batch("StandardName", [n["id"] for n in validated]) +``` + +**Acceptance:** +- `sn build --ids equilibrium --limit 10` generates rich entries +- Graph query shows StandardName nodes with documentation, kind, tags, unit +- Re-run doesn't null out existing fields +- `CANONICAL_UNITS` relationships exist +- StandardName nodes have non-null `embedding` property + +## Phase 5: Update validate worker + +**Files:** `imas_codex/sn/workers.py` (validate_worker) + +Add validation checks: +- `description` is present and <120 chars +- `documentation` is present and >200 chars +- `unit` is valid (matches known Unit nodes or standard SI patterns) +- `kind` is valid enum value +- `tags` are from controlled vocabulary +- `links` reference existing standard names (warn, don't fail) +- `ids_paths` contain valid DD paths (check via graph query) + +Report new metrics: `doc_present`, `doc_length_ok`, `unit_valid`, `kind_valid`. + +**Acceptance:** Validate worker reports new metrics in progress display. + +## Test Plan + +### New: `tests/sn/test_graph_ops.py` (CRITICAL — currently 0 tests) + +- `test_write_standard_names_creates_nodes` — write then read back all fields +- `test_write_standard_names_coalesce` — write once, write again with None fields, + verify first values preserved (prevents data corruption on re-runs) +- `test_write_standard_names_dd_relationship` — verify HAS_STANDARD_NAME created for DD +- `test_write_standard_names_signal_relationship` — verify HAS_STANDARD_NAME for signals +- `test_write_standard_names_unit_relationship` — verify CANONICAL_UNITS created +- `test_get_validated_standard_names_filters` — confidence and ids_filter work +- `test_get_existing_standard_names_dedup` — returns correct set + +### New: `tests/sn/conftest.py` + +Shared fixtures: sample candidates with all fields, mock GraphClient, state factory. + +### Existing test updates + +- `tests/sn/test_publish.py` — update for new SNCandidate fields +- `tests/sn/test_review.py` — update for new review_status values + +## Documentation Updates + +- `AGENTS.md` — document new schema fields, review_status lifecycle +- `plans/README.md` — update plan status diff --git a/plans/features/standard-names/12-catalog-import.md b/plans/features/standard-names/12-catalog-import.md new file mode 100644 index 000000000..f82982998 --- /dev/null +++ b/plans/features/standard-names/12-catalog-import.md @@ -0,0 +1,145 @@ +# 12: Catalog Feedback Import + +**Status:** Ready to implement +**Depends on:** 11 (rich schema — Phase 1 only) +**Enables:** 13 (publish dedup), 14 (SN MCP tools — richer context) +**Agent:** engineer (well-defined: CLI command + graph_ops function) + +## Problem + +After `sn publish` exports StandardName entries to YAML and a human reviews them +via a catalog PR, those edits need to flow back to the graph. Without this +feedback loop, the graph diverges from the reviewed catalog. + +**Clean break:** We are NOT importing the previous 309 catalog entries. The +`imas-standard-names-catalog` repo will be rebuilt from graph-generated data. +This plan implements the feedback import for the publish → review → import cycle. + +## Design: Option B+ Authority Model + +The catalog YAML is authoritative for **reviewed entries**. The graph is authoritative +for **drafted candidates** and operational metadata (embeddings, generation batches). + +**Authority boundaries:** + +| Owner | Fields | +|-------|--------| +| Catalog (YAML) | name, description, documentation, kind, unit, tags, links, ids_paths, status, constraints, validity_domain, provenance | +| Graph only | embedding, embedded_at, model, generated_at, review_status, confidence, source, source_path | +| Derived on import | grammar fields (from name parse), CANONICAL_UNITS edge (from unit string) | + +**Import rule:** Whole-entry import. Catalog fields always win. Graph-only fields +are preserved via coalesce. Imported entries get `review_status: accepted`. + +## Phase 1: `sn import-catalog` CLI command + +**Files:** +- `imas_codex/cli/sn.py` — add `import-catalog` subcommand +- `imas_codex/sn/catalog_import.py` — new module + +### CLI interface + +```bash +# Import reviewed entries from catalog checkout +imas-codex sn import-catalog --catalog-dir ../imas-standard-names-catalog/standard_names + +# Dry run — show what would be imported +imas-codex sn import-catalog --catalog-dir --dry-run + +# Import only specific tags +imas-codex sn import-catalog --catalog-dir --tags equilibrium,core-physics +``` + +### Import logic (`catalog_import.py`) + +```python +def import_catalog( + catalog_dir: Path, + dry_run: bool = False, + tag_filter: list[str] | None = None, +) -> ImportResult: + """Import YAML catalog entries into graph as accepted StandardName nodes. + + Reads all *.yml files from catalog_dir (recursive), parses each entry, + derives grammar fields via name parsing, and MERGEs into the graph. + + Imported entries get review_status='accepted'. Catalog fields overwrite + graph fields (catalog is authoritative). Graph-only fields preserved. + """ +``` + +Steps: +1. Walk `catalog_dir` recursively for `*.yml` files +2. Parse each YAML → validate against Pydantic import model +3. Derive grammar fields by parsing the standard name +4. MERGE StandardName nodes — catalog fields overwrite, graph-only fields preserved +5. Set `review_status = 'accepted'`, `imported_at = datetime()` +6. Derive `CANONICAL_UNITS` relationships from `unit` field +7. Derive `HAS_STANDARD_NAME` relationships from `ids_paths` field + (link to existing IMASNode nodes in the DD graph) +8. Report: imported, updated, skipped, errors + +### Graph write — coalesce for graph-only fields + +```cypher +UNWIND $items AS item +MERGE (sn:StandardName {id: item.name}) +SET sn.description = item.description, + sn.documentation = item.documentation, + sn.kind = item.kind, + sn.canonical_units = item.unit, + sn.tags = item.tags, + sn.links = item.links, + sn.ids_paths = item.ids_paths, + sn.constraints = item.constraints, + sn.validity_domain = item.validity_domain, + sn.review_status = 'accepted', + sn.imported_at = datetime(), + sn.physical_base = item.physical_base, + sn.subject = item.subject, + sn.component = item.component, + sn.coordinate = item.coordinate, + sn.position = item.position, + sn.process = item.process, + sn.created_at = coalesce(sn.created_at, datetime()), + sn.embedding = coalesce(sn.embedding, null), + sn.embedded_at = coalesce(sn.embedded_at, null) + +WITH sn, item +WHERE item.unit IS NOT NULL +MERGE (u:Unit {id: item.unit}) +MERGE (sn)-[:CANONICAL_UNITS]->(u) +``` + +Note: Uses `CANONICAL_UNITS` not `HAS_UNIT` per schema convention (range: Unit +on `canonical_units` slot auto-generates the relationship type). + +**Acceptance:** +- `sn import-catalog --catalog-dir --dry-run` reports entries found +- After import, nodes have `review_status: accepted` +- CANONICAL_UNITS relationships exist for entries with units +- Grammar fields derived from name parsing +- Graph-only fields (embedding, model, generated_at) preserved + +## Phase 2: Version tracking + +**Files:** `imas_codex/sn/catalog_import.py` + +Add version tracking to imported entries: +- `catalog_commit_sha` — git rev-parse HEAD of catalog repo at import time +- `imported_at` — timestamp + +Add `sn import-catalog --check` mode that reports whether graph entries +match current catalog without importing. + +**Acceptance:** +- Imported nodes have `catalog_commit_sha` property +- `sn import-catalog --check` reports sync status + +## Test Plan + +- Unit test: YAML parsing and grammar field derivation +- Unit test: import idempotency (re-import same catalog → no changes) +- Unit test: import preserves graph-only fields (embedding, model) +- Unit test: catalog fields overwrite graph fields (authority model) +- Integration test: publish → import round-trip preserves all data diff --git a/plans/features/standard-names/13-publish-pipeline.md b/plans/features/standard-names/13-publish-pipeline.md new file mode 100644 index 000000000..8ca8ea6c6 --- /dev/null +++ b/plans/features/standard-names/13-publish-pipeline.md @@ -0,0 +1,155 @@ +# 13: Publish Pipeline & Catalog Export + +**Status:** Ready to implement +**Depends on:** 11 (rich compose — all phases) +**Enables:** Human review workflow via catalog PRs → Plan 12 feedback import +**Agent:** engineer (well-defined: extend existing publish.py) + +## Problem + +The existing `sn publish` command generates YAML files but: +1. Exports only bare fields (name, kind, unit, tags, status, description) +2. Drops grammar fields, documentation, links, ids_paths, constraints +3. `kind` is hardcoded to "physical" instead of using catalog-standard values +4. Tags are only derived from IDS name, not from the full tag vocabulary +5. No round-trip guarantee — publish → import loses data + +## Design + +`sn publish` exports graph StandardName nodes (with `review_status: drafted`) +to YAML files matching the `imas-standard-names-catalog` directory structure. +The export must be **lossless** — `import-catalog` on the exported files must +reconstruct the same graph state (minus graph-only fields). + +## Phase 1: Fix lossy export + +**Files:** `imas_codex/sn/publish.py` + +Rewrite `generate_yaml_entry()` to include all catalog fields: + +```python +def generate_yaml_entry(sn: dict) -> dict: + """Convert a graph StandardName to catalog YAML format.""" + entry = { + "name": sn["name"], + "description": sn["description"], + "status": "draft", + "kind": sn.get("kind", "scalar"), + "unit": sn.get("canonical_units"), + "tags": sn.get("tags", []), + } + if sn.get("documentation"): + entry["documentation"] = sn["documentation"] + if sn.get("links"): + entry["links"] = [{"name": link} for link in sn["links"]] + if sn.get("ids_paths"): + entry["ids_paths"] = sn["ids_paths"] + if sn.get("constraints"): + entry["constraints"] = sn["constraints"] + if sn.get("validity_domain"): + entry["validity_domain"] = sn["validity_domain"] + if sn.get("model") or sn.get("source"): + entry["provenance"] = { + "mode": "generated", + "tool": "imas-codex", + "model": sn.get("model"), + "source": sn.get("source"), + "confidence": sn.get("confidence"), + } + return entry +``` + +### Output directory structure + +Match catalog layout — group by primary tag: + +``` +output/ + core-physics/ + electron_temperature.yml + ion_temperature.yml + equilibrium/ + safety_factor.yml + magnetic_axis_position.yml +``` + +**Acceptance:** +- Exported YAML contains all catalog fields +- Round-trip: publish → import-catalog → publish = same output +- `kind` reflects actual value, not hardcoded "physical" + +## Phase 2: Update graph query + +**Files:** `imas_codex/sn/graph_ops.py` + +Update `get_validated_standard_names()` to return all rich fields: + +```cypher +MATCH (sn:StandardName) +WHERE sn.review_status = 'drafted' +AND coalesce(sn.confidence, 1.0) >= $confidence_min +OPTIONAL MATCH (src)-[:HAS_STANDARD_NAME]->(sn) +OPTIONAL MATCH (src)-[:IN_IDS]->(ids:IDS) +OPTIONAL MATCH (sn)-[:CANONICAL_UNITS]->(u:Unit) +RETURN sn.id AS name, + sn.description AS description, + sn.documentation AS documentation, + sn.kind AS kind, + u.id AS canonical_units, + sn.tags AS tags, + sn.links AS links, + sn.ids_paths AS ids_paths, + sn.constraints AS constraints, + sn.validity_domain AS validity_domain, + sn.confidence AS confidence, + sn.model AS model, + sn.source AS source, + sn.physical_base AS physical_base, + sn.subject AS subject, + sn.component AS component, + sn.coordinate AS coordinate, + sn.position AS position, + sn.process AS process, + collect(DISTINCT ids.id) AS source_ids_names +ORDER BY sn.id +``` + +**Acceptance:** Query returns all rich fields for export. + +## Phase 3: Batched PR creation + +**Files:** `imas_codex/cli/sn.py` (sn_publish), `imas_codex/sn/publish.py` + +Enhance `--create-pr` workflow: +1. Group entries by primary tag (directory) +2. Create one PR per tag group (manageable review units) +3. PR title: `feat(sn): add {count} standard names for {tag}` +4. PR body: summary table of names with descriptions and confidence +5. Update graph: set `review_status = 'published'` for exported names + +**Acceptance:** +- `sn publish --create-pr --catalog-repo org/repo` creates PRs +- Each PR contains YAML files for one tag group +- Graph nodes updated to `review_status: published` + +## Phase 4: Duplicate detection + +**Files:** `imas_codex/sn/publish.py` + +Before export, check for existing entries in catalog: +1. Load existing catalog entries from `--catalog-dir` +2. Compare by name — skip exact matches +3. Detect near-duplicates (same name, different fields) — warn and show diff +4. Report: new, updated, unchanged, conflicts + +**Acceptance:** +- Publish with existing catalog dir skips already-present names +- Near-duplicate warnings shown in output + +## Test Plan + +- Unit test: `generate_yaml_entry()` round-trip with all fields +- Unit test: directory structure matches catalog layout +- Unit test: review_status transitions (drafted → published on export) +- Integration test: publish → import-catalog → publish = same output +- Unit test: duplicate detection against existing catalog diff --git a/plans/features/standard-names/14-mcp-tools-benchmark.md b/plans/features/standard-names/14-mcp-tools-benchmark.md new file mode 100644 index 000000000..53d0f7a1a --- /dev/null +++ b/plans/features/standard-names/14-mcp-tools-benchmark.md @@ -0,0 +1,161 @@ +# 14: Standard Name MCP Tools & Benchmark Enhancement + +**Status:** Ready to implement +**Depends on:** 11 (rich schema + embedding wiring) +**Enables:** MCP-assisted SN queries, model quality evaluation +**Agent:** engineer (MCP tools follow established patterns in llm/server.py) + +## Problem A: No SN search/fetch in imas-codex MCP + +The imas-codex MCP server (`imas_codex/llm/server.py`) has no tools for querying +standard names from the graph. Users and agents can't discover, search, or fetch +standard name entries. The graph is the richest data source (embeddings, DD links, +facility signal links, grammar decomposition). + +**Prerequisite:** Plan 11 Phase 4d wires embedding generation into the persist +worker. Without embeddings, vector search returns zero results. If deploying +before embeddings are wired, fall back to keyword-only search. + +## Problem B: Benchmark is grammar-only + +The `sn benchmark` command only measures grammar validity and reference overlap. +It doesn't evaluate documentation quality, naming conventions, semantic accuracy, +or entry completeness. No quality tiers. No reviewer model. + +## Part A: SN MCP Tools + +### Phase 1: Search standard names + +**Files:** +- `imas_codex/llm/sn_tools.py` — new module (follows search_tools.py pattern) +- `imas_codex/llm/server.py` — register tools + +Add `search_standard_names` tool: + +```python +@tool +def search_standard_names( + query: str, + kind: str | None = None, + tags: list[str] | None = None, + review_status: str | None = None, + k: int = 20, +) -> str: + """Search standard names by physics concept. + + Hybrid search (vector + keyword) over StandardName descriptions + and documentation. Enriched with DD path links, unit info, and + grammar decomposition. + """ +``` + +Uses `standard_name_desc_embedding` vector index for semantic search, +combined with keyword matching on name/tags. Falls back to keyword-only +if no embeddings present. + +### Phase 2: Fetch standard names + +Add `fetch_standard_names` tool: + +```python +@tool +def fetch_standard_names(names: str) -> str: + """Fetch full entries for known standard names. + + Returns complete metadata: description, documentation, unit, kind, + tags, links, ids_paths, grammar fields, provenance, review status. + """ +``` + +### Phase 3: List standard names + +Add `list_standard_names` tool: + +```python +@tool +def list_standard_names( + tag: str | None = None, + kind: str | None = None, + review_status: str | None = None, +) -> str: + """List standard names with optional filters. + + Returns name, description, kind, unit, status for each entry. + """ +``` + +**Acceptance:** +- MCP server exposes 3 SN tools +- `search_standard_names("electron temperature")` returns relevant names +- `fetch_standard_names("electron_temperature")` returns full entry +- `list_standard_names(tag="equilibrium")` returns filtered names + +## Part B: Benchmark Enhancement + +### Phase 4: Quality tier labels + +**Files:** `imas_codex/sn/benchmark_labels.yaml` — new file + +Curate quality labels for ~20 benchmark reference entries: + +```yaml +outstanding: # Rich docs, correct grammar, cross-linked, LaTeX + - electron_temperature + - plasma_current + - safety_factor + - position_of_magnetic_axis + - bootstrap_current +good: # Correct grammar, adequate docs + - toroidal_component_of_magnetic_field_at_magnetic_axis + - centroid_of_plasma_boundary + - bolometer_radiated_power + - collisionality +adequate: # Correct grammar, thin docs + - area_of_poloidal_magnetic_field_probe + - tokamak_scenario + - time +poor: # Grammar valid but naming debatable + - banana_orbits + - h_mode +``` + +### Phase 5: Reviewer model + +**Files:** `imas_codex/sn/benchmark.py`, `imas_codex/llm/prompts/sn/review_benchmark.md` + +Add `--reviewer-model` option that uses a frontier model to evaluate outputs: + +```bash +imas-codex sn benchmark \ + --models anthropic/claude-sonnet-4-6,google/gemini-2.5-flash \ + --reviewer-model anthropic/claude-opus-4-6 \ + --ids equilibrium --limit 50 +``` + +The reviewer receives: +1. Grammar rules (same as compose) +2. Labeled examples at each quality tier +3. Generated entry (name + all fields) +4. Rubric: grammar correctness, semantic accuracy, documentation quality, + naming conventions, unit consistency + +Returns per-entry: quality tier, score (0-100), reasoning. + +New metrics in benchmark report: +- **Quality distribution**: % outstanding / good / adequate / poor per model +- **Documentation richness**: avg doc length, equation count, cross-ref count +- **Grammar pattern coverage**: does model use subject, component, position, etc. +- **Entry completeness**: % of fields populated per entry + +**Acceptance:** +- `sn benchmark --reviewer-model ` produces quality-scored report +- Report includes quality distribution table per model +- Labeled examples used as scoring anchors + +## Test Plan + +- Unit tests for MCP tool registration and response format +- Unit test: search with and without embeddings (keyword fallback) +- Unit test: benchmark loads quality tier labels +- Unit test: quality tier classification against labeled examples +- Integration test: search returns graph-resident standard names diff --git a/plans/features/standard-names/superseded/09-llm-compose.md b/plans/features/standard-names/superseded/09-llm-compose.md new file mode 100644 index 000000000..547efb8df --- /dev/null +++ b/plans/features/standard-names/superseded/09-llm-compose.md @@ -0,0 +1,227 @@ +# 09: Schema Providers + LLM Compose + +**Status:** Pending +**Priority:** Critical — this is THE blocking gap +**Depends on:** Nothing (all infrastructure exists) +**Effort:** 2-3 days + +## Problem + +Two coupled problems must be solved together: + +### A. Impoverished prompt context + +The compose prompt (`sn/compose_dd.md`) receives bare enum lists from +`build_grammar_context()`: +```python +{"subjects": ["electron", "ion", ...], "positions": ["magnetic_axis", ...]} +``` + +Meanwhile, the imas-standard-names library has rich prompting infrastructure +that produces ~800 lines of structured context: +- 42 real examples grouped by composition pattern +- Segment descriptions with critical distinctions (component vs coordinate) +- Template application rules, exclusivity constraints +- Usage guidance per segment +- Tokamak parameters for grounding documentation examples +- Field schema guidance with common mistakes + +These are available as **direct Python imports** — no MCP needed: +```python +from imas_standard_names.grammar.constants import ( + SEGMENT_RULES, SEGMENT_ORDER, SEGMENT_TEMPLATES, + SEGMENT_TOKEN_MAP, EXCLUSIVE_SEGMENT_PAIRS, + APPLICABILITY_INCLUDE, GENERIC_PHYSICAL_BASES, +) +from imas_standard_names.grammar.field_schemas import ( + FIELD_GUIDANCE, TYPE_SPECIFIC_REQUIREMENTS, +) +from imas_standard_names.tools.grammar import ( + _build_canonical_pattern, _build_segment_order_constraint, + _get_segment_descriptions, _build_template_application_rule, + _build_segment_usage_guidance, +) +``` + +### B. Heuristic compose worker + +The `compose_worker` in `workers.py` uses a keyword-matching heuristic that +recognizes only 13 hardcoded words. The benchmark module already has a working +LLM compose pattern using `acall_llm_structured()`. + +## Approach + +### Phase 1a: Schema Provider System + +Create `imas_codex/sn/schema_providers.py` implementing the 3-tier caching +design from `plans/research/standard-names/06-schema-provider-design.md`. + +**Tier 1: Process-lifetime (static, ~15KB)** + +These import directly from `imas_standard_names` and are cached with +`@lru_cache(maxsize=1)`. They never change during a pipeline run. + +| Provider | Source | Content | +|----------|--------|---------| +| `grammar_context` | `grammar.constants.*`, `tools.grammar._build_*` | Canonical pattern, segment order, template rules, exclusivity pairs | +| `segment_descriptions` | `tools.grammar._get_segment_descriptions()` | Per-segment rich descriptions with critical distinctions | +| `segment_usage_guidance` | `tools.grammar._build_segment_usage_guidance()` | Usage patterns, example constructions per segment | +| `vocabulary_tokens` | `SEGMENT_TOKEN_MAP` | Token lists per segment with counts | +| `field_schema_guidance` | `FIELD_GUIDANCE`, `TYPE_SPECIFIC_REQUIREMENTS` | Per-field validation rules, common mistakes | + +**Tier 2: Catalog-lifetime (~4KB)** + +Loaded from the existing StandardName graph nodes or YAML catalog. +Invalidated on write. Cached per session. + +| Provider | Source | Content | +|----------|--------|---------| +| `existing_names_summary` | Graph query | Count, by-kind breakdown, sample names | +| `example_entries` | `resources/standard_name_examples/` (42 YAML files) | Curated high-quality entries for few-shot prompting | + +**Tier 3: Per-call dynamic (~3KB)** + +Assembled fresh for each LLM batch call. + +| Provider | Source | Content | +|----------|--------|---------| +| `dd_paths_context` | Extract worker output | Projected path info for the batch's IDS | +| `tokamak_parameters` | `resources/tokamak_parameters/*.yml` | Real machine dimensions for grounding doc examples | + +**Entry point:** +```python +async def get_schema_for_prompt( + schema_needs: list[str], + *, + dynamic_context: dict[str, Any] | None = None, +) -> dict[str, str]: + """Load requested providers and merge their output as prompt variables.""" +``` + +Each prompt template declares its `schema_needs` in frontmatter — only the +requested providers are loaded. This keeps prompt size predictable (~20KB). + +### Phase 1b: Rewrite compose prompt + +Rewrite `llm/prompts/sn/compose_dd.md` to consume the rich schema provider +output instead of bare enum lists. The prompt should reference: +- `{{ grammar_context }}` — canonical pattern, segment order, template rules +- `{{ segment_descriptions }}` — critical distinctions per segment +- `{{ vocabulary_tokens }}` — valid tokens per segment +- `{{ example_entries }}` — few-shot examples from the curated set +- `{{ dd_paths_context }}` — DD path info for the current batch +- `{{ tokamak_parameters }}` — machine parameters for grounding examples + +### Phase 1c: LLM compose worker + +Replace `_compose_single()` with batch LLM composition mirroring the +benchmark's `_run_model()` pattern: + +```python +async def compose_worker(state: SNBuildState, **_kwargs) -> None: + from imas_codex.discovery.base.llm import acall_llm_structured + from imas_codex.llm.prompt_loader import render_prompt + from imas_codex.sn.schema_providers import get_schema_for_prompt + from imas_codex.sn.models import SNComposeBatch + from imas_codex.settings import get_model + + model = get_model("language") + + # Load static + catalog context once + static_context = await get_schema_for_prompt([ + "grammar_context", "segment_descriptions", "vocabulary_tokens", + "field_schema_guidance", "example_entries", "existing_names_summary", + ]) + + # Group candidates by IDS for coherent batches + batches = _group_candidates_by_ids(state.candidates) + + composed = [] + for batch in batches: + # Load per-call context for this batch + dynamic_context = await get_schema_for_prompt( + ["dd_paths_context", "tokamak_parameters"], + dynamic_context={"ids_name": batch["group_key"], "items": batch["items"]}, + ) + prompt_context = {**static_context, **dynamic_context, **batch} + prompt_text = render_prompt("sn/compose_dd", prompt_context) + messages = [{"role": "user", "content": prompt_text}] + + result, cost, tokens = await acall_llm_structured( + model=model, messages=messages, response_model=SNComposeBatch, + ) + state.compose_stats.cost += cost + for c in result.candidates: + composed.append(c.model_dump()) + + state.composed = composed + state.compose_phase.mark_done() +``` + +## Files to Create/Modify + +### New: `imas_codex/sn/schema_providers.py` + +The schema provider system with 3-tier caching. + +### Modify: `imas_codex/llm/prompts/sn/compose_dd.md` + +Rewrite to consume rich schema provider variables instead of bare enums. +Add `schema_needs` frontmatter declaring required providers. + +### Modify: `imas_codex/sn/workers.py` + +- Delete `_compose_single()` and `_extract_physical_base()` (lines 172-247) +- Rewrite `compose_worker()` to use batch LLM calls with schema providers +- Rename `state.validated` → `state.composed` (see plan 10) + +### Modify: `imas_codex/sn/state.py` + +- Add `composed: list[dict]` field (rename from `validated`) + +### Modify: `imas_codex/sn/benchmark.py` + +- Replace `build_grammar_context()` with `get_schema_for_prompt()` call +- This ensures benchmark and pipeline use identical prompt context + +## Acceptance Criteria + +- Schema providers return ~15KB of static context (grammar, segments, vocabulary) +- `sn build --source dd --ids equilibrium` calls the LLM and produces real names +- Grammar round-trip validation passes for >80% of composed names +- Cost tracked in `state.compose_stats.cost` +- `--dry-run` still works (skips LLM, reports candidate count) +- Benchmark continues to work with the same schema providers + +## Testing + +- Unit test: schema providers return expected content structure and size +- Unit test: `@lru_cache` actually caches Tier 1 providers +- Integration: `sn build --source dd --ids equilibrium --dry-run` +- Integration: `sn build --source dd --ids equilibrium` (end-to-end) +- Integration: `sn benchmark --ids equilibrium --max-candidates 10` +- Quality: composed names parse via grammar round-trip + +## Tokamak Parameters for Documentation + +The 42 standard name example files in `resources/standard_name_examples/` +include `documentation` fields with LaTeX-formatted content referencing +real physical dimensions (e.g., "ITER major radius R₀ = 6.2 m"). The +tokamak parameters database (12 YAML files with sourced values) prevents +hallucinations in these documentation strings. + +For Phase 1, tokamak parameters are loaded as a per-call provider and +injected into the prompt alongside DD path context. The LLM uses real +machine parameters (major radius, minor radius, B_T, I_p, etc.) when +generating documentation examples instead of inventing plausible but +incorrect values. + +## Notes + +- The compose prompt already handles skipping metadata/index paths +- Existing names list prevents duplicates in the prompt +- `SNComposeBatch` model includes both `candidates` and `skipped` lists +- **Import backing functions directly** from `imas_standard_names` — never + call MCP tools from the pipeline +- The `_build_*` functions in `tools/grammar.py` are private but stable — + consider making them public or extracting their logic into `constants.py` diff --git a/plans/features/standard-names/superseded/10-pipeline-fixes.md b/plans/features/standard-names/superseded/10-pipeline-fixes.md new file mode 100644 index 000000000..2296a9086 --- /dev/null +++ b/plans/features/standard-names/superseded/10-pipeline-fixes.md @@ -0,0 +1,79 @@ +# 10: Pipeline Fixes + +**Status:** Pending +**Priority:** High — prevents wasted LLM budget +**Depends on:** 09 (LLM compose working) +**Effort:** 2-4 hours + +## Problem + +Two bugs in the current pipeline: + +### Bug 1: Extract dedup is a no-op + +`extract_worker` (workers.py:68-75) fetches existing standard names from the +graph but never uses them to filter candidates: + +```python +existing = get_existing_standard_names() # Fetched... +wlog.info("Found %d raw candidates, %d existing names", len(raw), len(existing)) +return raw # ...but never filtered! +``` + +Every `sn build` run will re-compose names that already exist in the graph, +wasting LLM budget. + +### Bug 2: Confusing field naming + +`compose_worker` stores its output in `state.validated` (line 158), which +is then read by the VALIDATE worker. But `validated` implies post-validation +data. This will confuse anyone reading the code. + +The state fields should follow the pipeline: `candidates → composed → reviewed → validated`. + +## Tasks + +### 1. Fix extract dedup + +In `extract_worker`, filter candidates against existing names: + +```python +existing = get_existing_standard_names() +existing_sources = {n.get("derived_from_dd") or n.get("derived_from_signal") + for n in existing if n.get("derived_from_dd") or n.get("derived_from_signal")} +filtered = [c for c in raw if c.get("path", c.get("signal_id")) not in existing_sources] +``` + +### 2. Rename state fields + +In `state.py`, rename for clarity: + +| Old | New | Written by | Read by | +|-----|-----|-----------|---------| +| `validated` (used as compose output) | `composed` | compose_worker | review_worker | +| `reviewed` | `reviewed` | review_worker | validate_worker | +| (new) | `validated` | validate_worker | (terminal / publish) | + +Update all references in `workers.py`. + +### 3. Review caps existing names smartly + +`_build_review_context()` (workers.py:439) caps existing names at 200: +```python +"existing_names": sorted(existing_names)[:200] +``` + +Instead, filter to names from the same IDS/domain as the current batch, +then cap at 200. This gives better dedup context. + +## Acceptance Criteria + +- Running `sn build` twice on the same IDS skips already-composed names on the second run +- State field names match the pipeline phase that writes them +- Review context includes domain-relevant existing names (not random 200) + +## Testing + +- Run `sn build --source dd --ids equilibrium` twice +- Second run should have fewer candidates (already-existing names filtered out) +- All tests pass with renamed state fields diff --git a/plans/features/standard-names/superseded/11-publish-validation.md b/plans/features/standard-names/superseded/11-publish-validation.md new file mode 100644 index 000000000..7c76e8f65 --- /dev/null +++ b/plans/features/standard-names/superseded/11-publish-validation.md @@ -0,0 +1,188 @@ +# 11: Publish Validation + Graph-Backed Staging + +**Status:** Pending +**Priority:** High — prevents publishing invalid entries to catalog +**Depends on:** 09 (schema providers + LLM compose), 10 (pipeline fixes) +**Effort:** 1-2 days + +## Problem + +Two issues with the current write/publish flow: + +### A. No validation gate + +The `sn publish` command converts StandardName graph nodes to YAML catalog +files, but only checks for filename collisions. It does NOT validate that +the generated entries conform to the catalog's grammar, schema, or semantic rules. + +### B. No staging or rollback + +The pipeline holds results in plain Python lists (`state.candidates`, +`state.composed`) with no transactional semantics. If the compose worker +crashes midway through a batch, partial results are lost. There's no +"validate-before-commit" gate — names go straight from the LLM to the +graph via `write_standard_names()`. + +The `imas-standard-names` UnitOfWork pattern provides exactly this: +- In-memory staging with add/update/remove/rename operations +- Undo stack with typed operations (UndoOpAdd, UndoOpDelete, etc.) +- Validation gate before commit +- Rollback on failure + +## Approach + +### Phase 4a: Validation gate for publish + +Import validation functions from `imas_standard_names` and run them +against generated entries before writing YAML. + +```python +from imas_standard_names.grammar import compose_standard_name, parse_standard_name +from imas_standard_names.grammar.field_schemas import FIELD_GUIDANCE +from imas_standard_names.services import validate_models +``` + +### Phase 4b: Graph-backed staging + +Adapt the UnitOfWork pattern for graph-backed persistence. The key insight: + +**The YAML UnitOfWork commits by writing YAML files. The graph UnitOfWork +commits by writing to Neo4j.** The staging, validation, undo, and rollback +mechanics are identical — only the persistence backend changes. + +```python +class GraphUnitOfWork: + """In-memory staging with graph persistence commit boundary.""" + + def __init__(self): + self._staged: dict[str, StandardNameEntry] = {} + self._undo: list[UndoOp] = [] + + def add(self, entry: StandardNameEntry) -> None: + if entry.name in self._staged: + raise ValueError(f"'{entry.name}' already staged") + self._staged[entry.name] = entry + self._undo.append(UndoOpAdd(entry.name)) + + def validate(self) -> list[str]: + """Run all validation checks. Returns list of error strings.""" + errors = [] + for entry in self._staged.values(): + # Grammar round-trip + errors.extend(self._check_grammar(entry)) + # Schema conformance + errors.extend(self._check_schema(entry)) + # Cross-entry semantic checks + errors.extend(self._check_semantic_conflicts()) + return errors + + def commit(self) -> int: + """Validate and write all staged entries to graph. + + Returns the number of entries written. Raises ValueError if + validation fails (entries remain staged for correction). + """ + issues = self.validate() + if issues: + raise ValueError("Validation failed:\n" + "\n".join(issues)) + written = write_standard_names([e.model_dump() for e in self._staged.values()]) + self._staged.clear() + self._undo.clear() + return written + + def rollback(self) -> None: + """Discard all staged entries.""" + self._staged.clear() + self._undo.clear() +``` + +The compose worker stages results instead of accumulating in plain lists: + +```python +# Before (no staging): +state.composed.append(result.model_dump()) + +# After (staged): +state.staging.add(StandardNameEntry(**result.model_dump())) +``` + +The validate worker calls `state.staging.validate()` and only commits +entries that pass all checks. + +## Files to Create/Modify + +### New: `imas_codex/sn/staging.py` + +Graph-backed UnitOfWork with: +- `add()`, `update()`, `remove()` with undo stack +- `validate()` importing `imas_standard_names` validation +- `commit()` calling `write_standard_names()` +- `rollback()` discarding staged entries + +### Modify: `imas_codex/sn/publish.py` + +Add validation step between entry generation and YAML writing: + +```python +for entry in entries: + # Grammar round-trip + parsed = parse_standard_name(entry.name) + recomposed = parsed.compose() + if recomposed != entry.name: + report_warning(f"Round-trip mismatch: {entry.name} → {recomposed}") + + # Schema validation + try: + create_standard_name_entry(entry.to_dict()) + except ValidationError as e: + report_error(f"Schema-invalid: {entry.name}: {e}") +``` + +### Modify: `imas_codex/sn/state.py` + +Add `staging: GraphUnitOfWork` field, replacing plain list accumulation. + +### Modify: `imas_codex/sn/workers.py` + +- Compose worker stages results via `state.staging.add()` +- Validate worker calls `state.staging.validate()` +- Final write uses `state.staging.commit()` + +## Acceptance Criteria + +- `sn publish --dry-run` shows validation summary: + ``` + Publish Validation: + Grammar: 42/45 passed (3 failed) + Schema: 41/45 passed (4 failed) + Publishable: 40 entries + ``` +- Grammar-invalid names are excluded from output +- Schema-invalid entries are excluded from output +- Compose worker uses staging instead of plain lists +- If compose worker crashes mid-batch, `rollback()` clears partial results +- `--force` flag overrides warnings (but not errors) + +## Testing + +- Unit test: GraphUnitOfWork add/validate/commit/rollback cycle +- Unit test: validation catches invalid grammar, missing fields, bad tags +- Unit test: undo stack correctly reverts operations +- Integration: `sn publish --dry-run` with known-bad entries +- Integration: `sn build` with crash simulation (verify rollback) + +## Design Decision: UnitOfWork vs Plain Lists + +**Why keep UnitOfWork even with Neo4j?** + +Neo4j has ACID transactions, but that only covers the *write* boundary. +The UnitOfWork pattern adds value at the *staging* boundary: + +1. **Validate-before-commit** — catch grammar/schema errors BEFORE touching + the graph, not after. Failed validation doesn't create orphan nodes. +2. **Batch rollback** — if the LLM produces garbage for one IDS batch, + discard just that batch without affecting earlier successes. +3. **Undo stack** — the review worker can flag entries for removal; + undo preserves the original for debugging. +4. **Clean separation** — "proposed" entries live in staging until explicitly + committed. The graph only contains validated, committed names. diff --git a/plans/research/standard-names/10-implementation-review.md b/plans/research/standard-names/10-implementation-review.md new file mode 100644 index 000000000..754daa94c --- /dev/null +++ b/plans/research/standard-names/10-implementation-review.md @@ -0,0 +1,379 @@ +# 10: Standard Names Implementation Review + +**Date:** 2026-04-08 +**Scope:** Post-implementation review of Features 05–08 +**Status:** Analysis complete — actionable findings + +--- + +## 1. Catalog Assessment (309 Existing Names) + +The imas-sn MCP server hosts **309 persisted standard names** — a substantial, high-quality catalog that serves as both the gold standard for benchmarking and the reference set for deduplication. + +### Quality Evaluation + +| Dimension | Assessment | Evidence | +|-----------|-----------|----------| +| Grammar validity | **Excellent** | 0 grammar errors, 0 schema errors across 309 entries | +| Documentation depth | **Outstanding** | Rich entries: LaTeX equations, governing physics, measurement methods, typical values across ITER/JET/DIII-D, sign conventions | +| Cross-referencing | **Good but fragile** | 810 forward-reference warnings — names linking to concepts not yet in catalog (e.g., `tokamak_scenario`, `gyrokinetic`) | +| Coverage breadth | **Strong core, gaps at edges** | 133 bare quantities, 32 subject-qualified, 40 device-qualified, 95 object-qualified, but only 3 component-qualified, 6 position-qualified, 0 process-qualified | +| Unit consistency | **Perfect** | 0 unit errors; proper SI notation (eV, m^-3, A, T, W.m^-2) | +| Provenance | **Empty** | All entries have empty `derived_from` — no DD path linking exists yet | + +### Name Distribution by Grammar Pattern + +``` +Bare physical_base only: 133 (43%) — temperature, safety_factor, time +Subject + physical_base: 32 (10%) — electron_temperature, ion_density +Device + physical_base: 40 (13%) — bolometer_radiated_power, flux_loop_... +Object (of_) qualified: 95 (31%) — area_of_flux_loop, position_of_... +Component (vector) qualified: 3 (1%) — toroidal_component_of_magnetic_field_... +Position (at_) qualified: 6 (2%) — ..._at_magnetic_axis +Process (due_to_) qualified: 0 (0%) — MISSING ENTIRELY +Binary operator compound: 0 (0%) — MISSING ENTIRELY +Transformation (square_of, etc): 0 (0%) — MISSING ENTIRELY +``` + +### Key Finding: Coverage Gaps + +The catalog has **zero names** using: +- `due_to_` pattern (e.g., `power_due_to_ohmic`, `current_due_to_bootstrap`) +- `binary_operator` compounds (e.g., `ratio_of_electron_pressure_to_magnetic_pressure`) +- `transformation` operators (e.g., `square_of_safety_factor`, `logarithm_of_collisionality`) +- `coordinate` prefix (geometric vector decomposition) + +These grammar features exist in the grammar API (26 processes, 3 binary operators, 4 transformations) but have no catalog exemplars. This is critical for benchmarking — models cannot learn these patterns from examples. + +### Name Quality Tiers (for benchmark reference) + +**Outstanding** (rich documentation, correct grammar, cross-linked): +- `electron_temperature` — 500+ word doc, Spitzer formula, 6 cross-references, LaTeX +- `plasma_current` — integral equation, Rogowski coil methods, sign convention +- `safety_factor` — q-profile equation, stability boundaries, measurement methods +- `bootstrap_current` — neoclassical theory, 16 cross-references +- `position_of_magnetic_axis` — Shafranov shift, coordinate system, geometric + +**Good** (correct grammar, adequate documentation): +- `toroidal_component_of_magnetic_field_at_magnetic_axis` — correct multi-field grammar +- `centroid_of_plasma_boundary` — geometric_base with geometry qualifier +- `bolometer_radiated_power` — device-qualified, calibration context +- `collisionality` — dimensionless quantity with physics context + +**Adequate** (correct grammar, thin documentation): +- `area_of_poloidal_magnetic_field_probe` — 482 chars, no cross-references +- `tokamak_scenario` — metadata kind, no governing equation +- `time` — minimal documentation for fundamental coordinate + +**Questionable** (grammar concerns or naming issues): +- `flux_surface_averaged_squared_toroidal_flux_coordinate_gradient_magnitude_divided_by_squared_magnetic_field_strength` — 113 characters as a single `physical_base`. Grammar API treats entire compound as open-vocabulary base rather than decomposing with transformations/operators. Valid but tests grammar extensibility limits. +- `h_mode`, `l_mode` — physics concepts rather than measurable quantities. `kind: metadata` is appropriate but no units. +- `banana_orbits`, `drift_waves` — conceptual entries, not quantities. Useful for cross-referencing but not measurable. + +--- + +## 2. Implementation vs. Plan Compliance + +### Feature 05: SN Build Pipeline ✅ + +| Deliverable | Status | Notes | +|------------|--------|-------| +| `sn/` module structure | ✅ Done | pipeline.py, workers.py, state.py, graph_ops.py, progress.py, models.py | +| `cli/sn.py` top-level group | ✅ Done | build, status, benchmark, publish commands | +| `llm/prompts/sn/` templates | ✅ Done | compose_dd.md, compose_signals.md | +| `sn/progress.py` display | ✅ Done | Uses StageDisplaySpec pattern | +| `sn/sources/` plugin system | ✅ Done | dd.py, signals.py, base.py | +| Graph schema updates | ⚠️ Partial | StandardName node exists but schema not in LinkML | +| End-to-end DD source | ✅ Done | 500 paths extracted for equilibrium IDS | + +**Critical gap:** The compose worker uses a **heuristic keyword matcher** (`_extract_physical_base()`) that matches against 13 known bases. The LLM compose prompt exists (`compose_dd.md`) but is NOT wired into the compose worker. In non-dry-run mode, composition quality will be very poor — only bare `physical_base` names like "temperature" or "density" can be produced, with no subject/component/position qualification. + +### Feature 06: Cross-Model Review ✅ + +| Deliverable | Status | Notes | +|------------|--------|-------| +| Review worker | ✅ Done | `review_worker()` in workers.py, batch processing | +| Review prompt template | ✅ Done | `sn/review.md` — comprehensive with grammar rules | +| Pydantic review models | ✅ Done | SNReviewVerdict, SNReviewItem, SNReviewBatch | +| Confidence tier classification | ✅ Done | In publish.py `confidence_tier()` | +| CLI options | ✅ Done | `--review-model`, `--skip-review` | +| Tests | ✅ Done | 19 tests covering accept/reject/revise paths | + +**Strength:** Clean buffer separation (validated → reviewed → validate reads from reviewed), graceful degradation on LLM failure, intra-run dedup tracking. + +### Feature 07: Benchmarking ⚠️ Needs Enhancement + +| Deliverable | Status | Notes | +|------------|--------|-------| +| CLI command | ✅ Done | `sn benchmark --models X,Y` | +| Multi-model support | ✅ Done | Runs each model sequentially | +| Quality scoring vs reference | ✅ Done | precision/recall against REFERENCE_NAMES | +| Rich comparison table | ✅ Done | Names, Valid%, Fields%, Ref Match, Cost, Speed | +| JSON report export | ✅ Done | BenchmarkReport.to_json() / from_json() | +| Benchmark dataset | ⚠️ Weak | 30 hand-crafted names — misses 90% of grammar patterns | + +**Opportunity:** The benchmark CLI is functional but the reference dataset is the wrong source of truth. The 309-entry catalog already exists and covers far more patterns. The benchmark should use the catalog as its gold standard, not 30 hand-picked entries. + +### Feature 08: Publish ✅ + +| Deliverable | Status | Notes | +|------------|--------|-------| +| YAML generation | ✅ Done | `generate_catalog_files()` | +| PR creation | ✅ Done | `create_catalog_pr()` via `gh` CLI | +| Batching by IDS/domain | ✅ Done | `make_publish_batches()` | +| Confidence tier separation | ✅ Done | `confidence_tier()` — high/medium/low | +| Catalog dedup | ✅ Done | `check_catalog_duplicates()` | +| PR description template | ✅ Done | Summary table in PR body | +| Dry-run mode | ✅ Done | `--dry-run` flag | + +--- + +## 3. Opportunity Gaps + +### Gap 1: Compose Worker Has No LLM — CRITICAL + +The compose worker in `workers.py` (lines 100-230) uses `_compose_single()` → `_extract_physical_base()` which is a 13-keyword heuristic. The Jinja2 prompt template `compose_dd.md` exists and is well-designed but is **not called**. This means: + +- Running `sn build --source dd --ids equilibrium` (without `--dry-run`) will produce only bare names like "temperature", "density", "current" — missing subject, component, position, and all other qualifiers +- The review and validate phases work correctly but operate on garbage input +- The benchmark module correctly wires the LLM via `_run_model()` — so the compose prompt works, it's just not integrated into the pipeline worker + +**Priority:** HIGH — this blocks any real standard name generation + +### Gap 2: Benchmark Reference Set Should Draw from Catalog + +The 30 entries in `benchmark_reference.py` are a hand-crafted subset covering: +- 6 simple physical bases (safety_factor, elongation, etc.) +- 6 subject-qualified (electron_temperature, ion_density, etc.) +- 5 component-qualified (j_tor, b_field_pol, etc.) +- 2 position-qualified +- 8 compound bases (plasma_current, loop_voltage, etc.) +- 3 geometric bases (minor_radius, major_radius, aspect_ratio) + +Missing from reference: +- 0 device-qualified names (40 exist in catalog) +- 0 object-qualified names (95 exist in catalog) +- 0 process-qualified (due_to_) patterns +- 0 binary operator patterns +- 0 transformation patterns +- 0 coordinate (geometric vector) patterns + +**Opportunity:** Use the 309-entry catalog as the authoritative reference. The benchmark should: +1. Load catalog entries via the imas-sn grammar API or MCP tools +2. Parse each catalog entry to extract grammar fields +3. Use these as the ground truth for precision/recall +4. Curate a **stratified** subset covering all grammar patterns at quality tiers + +### Gap 3: Benchmark Needs Curated Labeled Examples for Prompt Injection + +The user's key insight: the benchmark should not just measure pass/fail rates — it should **inject labeled examples** (poor → outstanding) into prompts and use an Opus 4.6 reviewer to evaluate how different models perform on nuanced quality dimensions. + +**Proposed labeled example tiers:** + +| Tier | Criteria | Example | Why This Tier | +|------|----------|---------|---------------| +| **Outstanding** | Correct grammar, rich documentation, cross-linked, precise units, appropriate kind | `electron_temperature` | Full physics context, Spitzer formula, measurement methods, typical values | +| **Good** | Correct grammar, adequate documentation, proper units | `toroidal_component_of_magnetic_field_at_magnetic_axis` | Multi-field grammar correct, but documentation could be richer | +| **Adequate** | Correct grammar, minimal documentation | `area_of_poloidal_magnetic_field_probe` | Grammar right but bare documentation, no cross-references | +| **Poor** | Grammar valid but naming questionable | `flux_surface_averaged_squared_toroidal_flux_coordinate_gradient_magnitude_divided_by_squared_magnetic_field_strength` | Should decompose with transformations rather than cramming into physical_base | +| **Conceptual** | Valid but not a measurable quantity | `banana_orbits`, `tokamak_operation`, `h_mode` | Physics concepts, not quantities — should use `kind: metadata` consistently | + +**Benchmark workflow with Opus 4.6 reviewer:** +1. Extract DD paths (same as current) +2. Run each candidate model to generate names + documentation +3. Inject labeled examples into an Opus 4.6 review prompt: + - "Here are examples at different quality levels: [Outstanding: ..., Good: ..., Poor: ...]" + - "For each generated name, rate it on these dimensions and assign a tier" +4. Opus 4.6 returns per-name quality assessments with reasoning +5. Aggregate into per-model quality distributions + +This transforms the benchmark from a binary "grammar valid/invalid" check into a **nuanced quality evaluation** that can distinguish between models producing technically valid but semantically poor names vs. models producing outstanding names. + +### Gap 4: Model Name Format Issues + +The benchmark CLI examples show `--models claude-sonnet-4,gpt-4o` but pyproject.toml uses `anthropic/claude-sonnet-4-6` format. The benchmark passes model strings directly to `acall_llm_structured()` without the `openrouter/` prefix required for cache_control preservation. + +### Gap 5: No Signals Source Testing + +The signals source (`sn/sources/signals.py`) exists but has no tests and has never been exercised end-to-end. The `compose_signals.md` prompt template exists but isn't wired into the worker either (same issue as compose_dd). + +### Gap 6: StandardName Not in LinkML Schema + +The `StandardName` node type is used in `graph_ops.py` but is not declared in `imas_codex/schemas/facility.yaml`. This means: +- No generated Pydantic model for StandardName +- No schema compliance tests +- No vector index management +- Properties are ad-hoc (written in Cypher SET statements) + +### Gap 7: No Graph Write in Pipeline + +The pipeline runs EXTRACT → COMPOSE → REVIEW → VALIDATE but never writes results to the graph. The `write_standard_names()` function exists in `graph_ops.py` but is never called from any worker. Generated names exist only in memory during the pipeline run and are lost when it completes. + +--- + +## 4. Benchmark CLI Enhancement Strategy + +The benchmark CLI (`sn benchmark`) is a **strength** if enhanced correctly. Rather than removing it, enhance it to become a comprehensive quality evaluation tool. + +### Current Architecture (keep) +- `BenchmarkConfig` — model list, source/filter config, temperature +- `ModelResult` — per-model metrics (grammar valid, cost, speed, reference overlap) +- `BenchmarkReport` — aggregated results with JSON serialization +- `render_comparison_table()` — Rich table output +- Grammar validation (`validate_candidate()`) — round-trip parse/compose +- Reference comparison (`compare_to_reference()`) — precision/recall + +### Enhancements Needed + +#### A. Dynamic Reference from Catalog +Replace `REFERENCE_NAMES` dict with a function that loads from the imas-sn catalog: + +```python +def load_catalog_reference(ids_filter=None, max_entries=100): + """Load reference names from the imas-sn catalog. + + Returns dict mapping canonical_name → {name, grammar_fields, kind, unit, quality_tier} + """ +``` + +#### B. Stratified Quality Labels +Curate a quality label file (`benchmark_labels.yaml`) mapping names to quality tiers: + +```yaml +outstanding: + - electron_temperature + - plasma_current + - safety_factor + - position_of_magnetic_axis + - centroid_of_plasma_boundary +good: + - toroidal_component_of_magnetic_field_at_magnetic_axis + - bolometer_radiated_power + - collisionality +adequate: + - area_of_poloidal_magnetic_field_probe + - tokamak_scenario + - time +poor: + - flux_surface_averaged_squared_toroidal_flux_coordinate_gradient_magnitude_divided_by_squared_magnetic_field_strength +conceptual: + - banana_orbits + - h_mode + - tokamak_operation +``` + +#### C. Opus 4.6 Quality Reviewer +Add a `--reviewer-model` option that uses a frontier model to evaluate generated outputs: + +``` +imas-codex sn benchmark \ + --models anthropic/claude-sonnet-4-6,google/gemini-2.5-flash \ + --reviewer-model anthropic/claude-opus-4-6 \ + --ids equilibrium \ + --max-candidates 50 +``` + +The reviewer model receives: +1. Grammar rules (same as compose prompt) +2. Labeled examples at each quality tier +3. The generated name + fields + documentation +4. Rubric: grammar correctness, semantic accuracy, naming conventions, documentation quality, unit consistency + +Returns per-name quality tier assignment with reasoning. + +#### D. Additional Metrics +- **Quality distribution**: % outstanding / good / adequate / poor per model +- **Grammar pattern coverage**: Does the model use subject, component, position, process, etc.? +- **Documentation richness**: Average doc length, equation count, cross-reference count +- **Naming consistency**: Same input → same output across temperature settings + +--- + +## 5. Curated Benchmark Examples + +### Outstanding Tier (5 examples) + +These names demonstrate mastery of grammar, physics, documentation, and conventions: + +1. **`electron_temperature`** — Subject-qualified scalar. Rich LaTeX documentation (Spitzer formula, thermal velocity equation). Typical values across 3 devices. 6+ cross-references. Unit: eV. + +2. **`plasma_current`** — Bare compound base. Surface integral equation with proper notation. Sign convention documented. Measurement methods (Rogowski, equilibrium reconstruction). Unit: A. + +3. **`safety_factor`** — Fundamental dimensionless quantity. Ratio definition with field line integrals. Stability boundary context (q>1, q>2). Unit: 1. + +4. **`position_of_magnetic_axis`** — Geometric base (`position`) + geometry (`magnetic_axis`). Shafranov shift explained. Kind: vector. Unit: m. + +5. **`bootstrap_current`** — Process-related physics. 16 cross-references (most in catalog). Neoclassical theory context. Unit: A.m^-2. + +### Good Tier (5 examples) + +Correct grammar, adequate physics, could improve documentation: + +6. **`toroidal_component_of_magnetic_field_at_magnetic_axis`** — Component + physical_base + position. Multi-field grammar correctly composed. Documentation adequate but shorter. + +7. **`centroid_of_plasma_boundary`** — Geometric base (`centroid`) + geometry (`plasma_boundary`). Bounding box formula. Kind: vector. + +8. **`bolometer_radiated_power`** — Device-qualified. Calibration and measurement context. Unit: W.m^-2. + +9. **`collisionality`** — Dimensionless parameter. Proper physics context linking to Coulomb collision theory. + +10. **`outline_of_plasma_boundary`** — Geometric contour with parameterized equation. Kind: vector. + +### Adequate Tier (5 examples) + +Grammar correct, thin documentation, missing context: + +11. **`area_of_poloidal_magnetic_field_probe`** — Object-qualified. Faraday's law reference but minimal. No typical values. + +12. **`time`** — Fundamental coordinate. Very brief documentation for such an important quantity. + +13. **`tokamak_scenario`** — Metadata kind. No governing equation (appropriate). Brief listing of scenario types. + +14. **`neutron_activation_analysis`** — Measurement technique as a standard name. Good documentation but unusual — describes a technique rather than a quantity. + +15. **`skin_current`** — Bare physical base. Minimal documentation for a nuanced concept. + +### Poor/Questionable Tier (5 examples) + +Grammar valid but naming approach debatable: + +16. **`flux_surface_averaged_squared_toroidal_flux_coordinate_gradient_magnitude_divided_by_squared_magnetic_field_strength`** — 113 characters crammed into `physical_base`. Should use transformation operators (`square_of`) and binary operator (`ratio_of`) to decompose. + +17. **`banana_orbits`** — Conceptual physics phenomenon, not a measurable quantity. `kind: metadata` but no clear use case for standard naming. + +18. **`h_mode`** — Confinement regime concept. Documented well but not measurable. Should perhaps be in a separate conceptual namespace. + +19. **`magnetic_field_probe_vertical_field`** — Ambiguous: is this `vertical_component_of_magnetic_field_of_probe` or `magnetic_field_measured_by_vertical_probe`? Grammar doesn't disambiguate. + +20. **`hot_neutral_temperature_of_isotope`** — Valid grammar but unusual: `subject` should be `neutral` with `physical_base: temperature`. Instead uses compound physical_base. + +--- + +## 6. Summary of Findings + +### What Works Well +- 4-phase pipeline architecture (EXTRACT → COMPOSE → REVIEW → VALIDATE) +- Review phase with cross-model LLM and graceful degradation +- Publish module with YAML generation, batching, and dedup +- 87 tests across 3 test modules +- Rich progress display integration +- Existing catalog (309 entries) is high quality + +### What Needs Work (Priority Order) +1. **Wire LLM into compose worker** — currently heuristic-only (CRITICAL) +2. **Upgrade benchmark reference** — use catalog, add quality tiers, add reviewer model +3. **Fix model name format** — add openrouter/ prefix handling +4. **Add StandardName to LinkML schema** — enable schema compliance +5. **Wire graph write into pipeline** — currently results are lost +6. **Test signals source** — untested code path +7. **Fill grammar pattern gaps** — catalog needs process, transformation, binary operator examples + +### Benchmark Enhancement Path +The benchmark CLI is a **strength** if enhanced with: +- Catalog-sourced reference set (309 → dynamic) +- Stratified quality labels (outstanding → poor) +- Frontier model reviewer (Opus 4.6) +- Grammar pattern coverage metrics +- Documentation quality scoring + +This transforms it from "grammar pass/fail counter" to "comprehensive model quality evaluator" — providing robust, actionable metrics for model selection. diff --git a/plans/features/standard-names/01-grammar-api-exports.md b/plans/research/standard-names/archived-implemented/01-grammar-api-exports.md similarity index 100% rename from plans/features/standard-names/01-grammar-api-exports.md rename to plans/research/standard-names/archived-implemented/01-grammar-api-exports.md diff --git a/plans/features/standard-names/02-dd-path-linking.md b/plans/research/standard-names/archived-implemented/02-dd-path-linking.md similarity index 100% rename from plans/features/standard-names/02-dd-path-linking.md rename to plans/research/standard-names/archived-implemented/02-dd-path-linking.md diff --git a/plans/features/standard-names/03-grammar-extensions.md b/plans/research/standard-names/archived-implemented/03-grammar-extensions.md similarity index 100% rename from plans/features/standard-names/03-grammar-extensions.md rename to plans/research/standard-names/archived-implemented/03-grammar-extensions.md diff --git a/plans/features/standard-names/04-json-schema-contract.md b/plans/research/standard-names/archived-implemented/04-json-schema-contract.md similarity index 100% rename from plans/features/standard-names/04-json-schema-contract.md rename to plans/research/standard-names/archived-implemented/04-json-schema-contract.md diff --git a/plans/features/standard-names/05-sn-build-pipeline.md b/plans/research/standard-names/archived-implemented/05-sn-build-pipeline.md similarity index 100% rename from plans/features/standard-names/05-sn-build-pipeline.md rename to plans/research/standard-names/archived-implemented/05-sn-build-pipeline.md diff --git a/plans/features/standard-names/06-cross-model-review.md b/plans/research/standard-names/archived-implemented/06-cross-model-review.md similarity index 100% rename from plans/features/standard-names/06-cross-model-review.md rename to plans/research/standard-names/archived-implemented/06-cross-model-review.md diff --git a/plans/features/standard-names/07-benchmarking.md b/plans/research/standard-names/archived-implemented/07-benchmarking.md similarity index 100% rename from plans/features/standard-names/07-benchmarking.md rename to plans/research/standard-names/archived-implemented/07-benchmarking.md diff --git a/plans/features/standard-names/08-publish.md b/plans/research/standard-names/archived-implemented/08-publish.md similarity index 100% rename from plans/features/standard-names/08-publish.md rename to plans/research/standard-names/archived-implemented/08-publish.md diff --git a/plans/research/standard-names/archived-v1/09-llm-compose-integration.md b/plans/research/standard-names/archived-v1/09-llm-compose-integration.md new file mode 100644 index 000000000..7e94bb6f1 --- /dev/null +++ b/plans/research/standard-names/archived-v1/09-llm-compose-integration.md @@ -0,0 +1,218 @@ +# Feature 09: LLM Compose Integration + +**Status:** Pending +**Priority:** CRITICAL — blocks all downstream phases +**Depends on:** Features 05 (pipeline exists), 01 (grammar API exports) +**Parallel with:** 11a (schema alignment), 12 (benchmark enhancement) +**Estimated complexity:** Medium-high + +--- + +## Problem + +The compose worker (`workers.py:96–248`) uses a 13-keyword heuristic +(`_extract_physical_base()`) instead of LLM calls. The compose prompt +templates (`compose_dd.md`, `compose_signals.md`) exist and are well-designed +but are **not wired** into the pipeline. The benchmark module (`_run_model()`) +correctly uses `acall_llm_structured()` with the same prompts — proving the +integration pattern works — but the build pipeline itself cannot produce +quality names. + +## Approach + +Replace the heuristic compose worker with batched async LLM calls following +the proven `review_worker` pattern. Refactor the extract→compose data flow +to use `ExtractionBatch` objects (already defined in `sn/sources/base.py`) +instead of flat dicts. + +--- + +## Phase 1: Extract→Compose Data Contract + +The extract worker currently stores flat `list[dict]` in `state.candidates`. +The compose worker needs grouped batches with IDS/domain context for coherent +LLM prompts. + +### Tasks + +1. **Add `extraction_batches` field to `SNBuildState`** + - File: `imas_codex/sn/state.py` + - Type: `list[ExtractionBatch]` (from `sn/sources/base.py`) + - Extract worker populates this instead of (or in addition to) `state.candidates` + +2. **Refactor `extract_worker` to use source modules** + - File: `imas_codex/sn/workers.py` (lines 34–88) + - DD source: call `extract_dd_candidates()` from `sn/sources/dd.py` + - Signals source: call `extract_signal_candidates()` from `sn/sources/signals.py` + - Store results as `state.extraction_batches` + - Keep `state.candidates` as a flat view for progress tracking + +3. **Add compose model config** + - Add `compose_model` field to `SNBuildState` + - CLI: `--compose-model` option in `sn build` (default: `get_model("language")`) + - File: `imas_codex/cli/sn.py` + +### Acceptance Criteria +- Extract worker produces `ExtractionBatch` objects with group_key, items, existing_names +- State exposes both batched and flat views of candidates +- Compose model is configurable from CLI + +--- + +## Phase 2: Wire LLM into Compose Worker + +Replace the heuristic with batched async LLM calls matching the benchmark's +`_run_model()` pattern. + +### Tasks + +1. **Rewrite `compose_worker()` for batched LLM** + - File: `imas_codex/sn/workers.py` + - Remove `_compose_single()` and `_extract_physical_base()` entirely + - Add `_compose_batch()` async function following `_review_batch()` pattern: + ```python + async def _compose_batch( + batch: ExtractionBatch, + model: str, + grammar_enums: dict, + wlog: logging.LoggerAdapter, + ) -> tuple[list[dict], int, float, int]: + """Compose standard names for one extraction batch via LLM.""" + prompt_template = "sn/compose_dd" if batch.source == "dd" else "sn/compose_signals" + context = { + "items": batch.items, + "ids_name": batch.group_key, # or facility/domain for signals + "existing_names": sorted(batch.existing_names), + **grammar_enums, + } + # For signals source, add facility and domain context + if batch.source == "signals": + context["facility"] = batch.group_key.split("/")[0] # or from state + context["domain"] = batch.group_key + + prompt_text = render_prompt(prompt_template, context) + messages = [{"role": "user", "content": prompt_text}] + + result, cost, tokens = await acall_llm_structured( + model=model, + messages=messages, + response_model=SNComposeBatch, + ) + # Convert SNCandidate objects to pipeline dicts + composed = [] + for c in result.candidates: + composed.append({ + "id": c.standard_name, + "source_type": batch.source, + "source_id": c.source_id, + **c.fields, + "confidence": c.confidence, + "reason": c.reason, + "units": _find_units(batch.items, c.source_id), + }) + return composed, len(result.skipped), cost, tokens + ``` + +2. **Integrate batch loop into compose_worker()** + - Iterate over `state.extraction_batches` + - Call `_compose_batch()` for each + - Track cost in `state.compose_stats.cost` + - Track tokens for logging + - Respect `state.should_stop()` between batches + - Record batch progress every N batches + +3. **Reuse `_get_grammar_enums()`** + - Already exists at line 258 — use it in compose worker too + - Consider moving to a shared location (e.g., `sn/grammar_context.py`) + +### Acceptance Criteria +- `sn build --source dd --ids equilibrium` produces LLM-generated names with subject, component, position qualifiers +- Cost and token tracking works in compose phase +- `sn build --dry-run` still skips LLM calls +- `sn build --source signals --facility tcv` works with signals prompt + +--- + +## Phase 3: Prompt Enhancement + +The existing compose prompts generate name + fields + confidence + reason, +but NOT rich documentation. Compose should produce a short `reason` only. +Full documentation generation is deferred to Feature 10 (DOCUMENT phase). + +However, the prompts need minor improvements for quality: + +### Tasks + +1. **Add few-shot examples to compose prompts** + - File: `imas_codex/llm/prompts/sn/compose_dd.md` + - Add 5-8 diverse examples covering all grammar patterns: + - Subject-qualified: `electron_temperature` + - Component-qualified: `toroidal_component_of_magnetic_field` + - Position-qualified: `electron_temperature_at_magnetic_axis` + - Process-qualified: `power_due_to_ohmic` + - Device-qualified: `bolometer_radiated_power` + - Geometric base: `position_of_magnetic_axis` + - Similarly for `compose_signals.md` + +2. **Add system/user message split for prompt caching** + - Current: single user message with everything + - Better: system message (grammar rules + enums + examples) + user message (batch items) + - System message is static → cacheable via `inject_cache_control()` + - Modify `_compose_batch()` to use two-message format + +3. **Validate compose output against grammar** + - After LLM returns `SNComposeBatch`, run `parse_standard_name()` on each candidate + - Log warnings for grammar-invalid names but keep them for review phase + - Add `grammar_valid` field to pipeline dict + +### Acceptance Criteria +- Compose prompts include diverse few-shot examples +- System/user message split enables prompt caching +- Grammar validation runs inline during compose + +--- + +## Phase 4: Tests + +### Tasks + +1. **Unit tests for compose worker with mocked LLM** + - File: `tests/sn/test_compose_worker.py` + - Mock `acall_llm_structured` to return pre-built `SNComposeBatch` + - Test: DD source produces expected pipeline dicts + - Test: Signals source uses correct prompt template + - Test: Dry-run mode skips LLM + - Test: Empty candidates handled gracefully + - Test: Cost tracking accumulates correctly + - Test: `should_stop()` interrupts batch loop + +2. **Integration test with extraction batches** + - Verify `extract_worker` → `compose_worker` data flow + - Verify `ExtractionBatch` → compose → validate chain + +3. **Test prompt rendering** + - Verify templates render without errors for DD and signals contexts + - Verify grammar enums are populated + +### Acceptance Criteria +- All tests pass with mocked LLM +- Coverage ≥95% on new/modified code in workers.py + +--- + +## Files Modified + +| File | Change | +|------|--------| +| `imas_codex/sn/workers.py` | Rewrite compose_worker, delete heuristics | +| `imas_codex/sn/state.py` | Add extraction_batches, compose_model | +| `imas_codex/cli/sn.py` | Add --compose-model flag | +| `imas_codex/llm/prompts/sn/compose_dd.md` | Add few-shot examples | +| `imas_codex/llm/prompts/sn/compose_signals.md` | Add few-shot examples | +| `tests/sn/test_compose_worker.py` | New test file | +| `tests/sn/test_workers.py` | Update existing tests | + +## Documentation Updates + +- AGENTS.md: Document `--compose-model` CLI option +- Prompt templates: Self-documenting via frontmatter diff --git a/plans/research/standard-names/archived-v1/10-documentation-and-linking.md b/plans/research/standard-names/archived-v1/10-documentation-and-linking.md new file mode 100644 index 000000000..f9a0d4ee4 --- /dev/null +++ b/plans/research/standard-names/archived-v1/10-documentation-and-linking.md @@ -0,0 +1,203 @@ +# Feature 10: Documentation Generation + +**Status:** Pending +**Priority:** High — names without documentation are low-value +**Depends on:** Feature 09 (LLM compose must be working) +**Parallel with:** 12 (benchmark Phase 1) +**Estimated complexity:** Medium-high + +--- + +## Problem + +The SN pipeline generates names with grammar fields and a short `reason`, +but no rich documentation. The existing 309-entry catalog demonstrates the +quality bar: entries like `electron_temperature` have 500+ word descriptions, +LaTeX equations, governing physics, measurement methods, typical values, and +cross-references to other standard names. + +Generated names without this documentation will be rejected during catalog +review. + +## Approach + +Add a DOCUMENT phase after VALIDATE that generates rich documentation via +LLM. This is a **separate concern from link validation** (Plan 14) and +**separate from quality scoring** (Plan 12). The revised pipeline: + +``` +EXTRACT → COMPOSE → REVIEW → VALIDATE → DOCUMENT → PERSIST_NODES → LINK → SCORE → PERSIST_GRAPH +``` + +DOCUMENT focuses solely on generating high-quality physics documentation +for each validated name. Cross-reference mentions in the documentation are +recorded as structured data but **not validated here** — that's the LINK +phase's job (Plan 14) which runs after nodes are persisted to the graph. + +--- + +## Phase 1: DOCUMENT Phase — LLM Documentation Generation + +### Design + +A new `document_worker()` that takes validated names and generates rich +documentation for each. This is a separate LLM call from compose because: + +1. Compose focuses on *naming accuracy* (grammar fields, name selection) +2. Documentation focuses on *explanation quality* (physics context, equations, + measurement methods, typical values, cross-references) +3. Different prompt structure: compose needs batch context (many paths at once), + document needs deep context per name (similar names, DD path details, units) +4. Token budget: combining both would exceed context windows for complex names + +### Tasks + +1. **Create documentation prompt template** + - File: `imas_codex/llm/prompts/sn/document.md` + - System message (static, cacheable): + - Role definition: fusion physics documentation expert + - Documentation quality rubric (from review doc Section 5): + - Outstanding: LaTeX equations, governing physics, measurement methods, + typical values across devices (ITER/JET/DIII-D), sign conventions, + cross-references + - Good: physics context, relevant equations, measurement approaches + - Adequate: definition, units, basic physics context + - 3-5 exemplar entries at Outstanding tier (full YAML) + - Grammar rules summary (for context, not for naming) + - User message (dynamic): + - The standard name and its grammar fields + - Source DD path description and metadata + - Related names from catalog (for cross-referencing) + - Units and data type + +2. **Create Pydantic response models** + - File: `imas_codex/sn/models.py` + ```python + class SNDocumentation(BaseModel): + """Rich documentation for a single standard name.""" + source_id: str + standard_name: str + kind: str # physical, geometric, metadata + description: str # Rich markdown with LaTeX + governing_equations: list[str] # LaTeX equation strings + measurement_methods: list[str] # How this quantity is measured + typical_values: dict[str, str] # device → "range (units)" + cross_reference_mentions: list[str] # Other SN IDs mentioned in docs + dependency_mentions: list[str] # SNs this quantity functionally depends on + tags: list[str] # Classification tags + sign_convention: str | None # Sign convention notes + ``` + **Key distinction**: `cross_reference_mentions` and `dependency_mentions` + are **unvalidated** text extracted from the LLM output. They become + graph relationships only after the LINK phase (Plan 14) validates them. + + ```python + class SNDocumentBatch(BaseModel): + """LLM response for batch documentation generation.""" + entries: list[SNDocumentation] + ``` + +3. **Implement `document_worker()`** + - File: `imas_codex/sn/workers.py` + - Pattern: follows `review_worker()` structure + - Reads from `state.validated` (or `state.reviewed`) + - Batches: 3-5 names per LLM call (smaller batches than compose + because documentation is verbose) + - Stores results in `state.documented: list[dict]` + - Tracks cost in `state.document_stats` + - Uses `get_model("reasoning")` by default (documentation requires + deeper physics knowledge) + - CLI: `--document-model` and `--skip-document` flags + +4. **Add DOCUMENT phase to pipeline** + - File: `imas_codex/sn/pipeline.py` + - New WorkerSpec between VALIDATE and PERSIST_NODES: + ```python + WorkerSpec( + "document", + "document_phase", + document_worker, + depends_on=["validate_phase"], + enabled=not state.skip_document, + ), + ``` + +5. **Add state fields** + - File: `imas_codex/sn/state.py` + - `documented: list[dict]` — documented names + - `document_stats: WorkerStats` — phase tracking + - `document_phase: PipelinePhase` — supervision + - `skip_document: bool = False` — CLI control + - `document_model: str | None = None` — model override + - Update `total_cost` property to include document phase + +### Acceptance Criteria +- `sn build --source dd --ids equilibrium` generates documentation for each name +- Documentation includes governing equations, measurement methods, typical values +- Cross-reference mentions are extracted but NOT validated in this phase +- `--skip-document` flag bypasses the phase +- Cost tracking includes document phase + +--- + +## Phase 2: Progress Display Updates + +### Tasks + +1. **Add DOCUMENT stage to progress display** + - File: `imas_codex/sn/progress.py` + - New `StageDisplaySpec` entry for document phase + - Shows cost (LLM), rate, current name being documented + +2. **Update pipeline summary** + - File: `imas_codex/cli/sn.py` + - Summary table includes document stats + - Total cost includes compose + review + document phases + +### Acceptance Criteria +- Progress display shows DOCUMENT stage with cost tracking +- Resource section includes updated cost totals + +--- + +## Phase 3: Tests + +### Tasks + +1. **Test document worker** + - File: `tests/sn/test_document_worker.py` + - Mock `acall_llm_structured` returning `SNDocumentBatch` + - Verify documentation fields are populated + - Verify `cross_reference_mentions` and `dependency_mentions` extracted + - Verify cost tracking + - Test skip-document mode + - Test empty input handling + +2. **Test prompt template rendering** + - Verify `sn/document.md` renders without errors + - Verify exemplar entries are included + - Verify system/user message split for cache efficiency + +### Acceptance Criteria +- All tests pass with mocked LLM +- No graph or MCP dependency in tests + +--- + +## Files Modified / Created + +| File | Change | +|------|--------| +| `imas_codex/sn/workers.py` | Add `document_worker()` | +| `imas_codex/sn/state.py` | Add documented, document_stats, document_phase | +| `imas_codex/sn/pipeline.py` | Add DOCUMENT WorkerSpec | +| `imas_codex/sn/models.py` | Add SNDocumentation, SNDocumentBatch | +| `imas_codex/sn/progress.py` | Add document stage display | +| `imas_codex/cli/sn.py` | Add --skip-document, --document-model | +| `imas_codex/llm/prompts/sn/document.md` | New prompt template | +| `tests/sn/test_document_worker.py` | New test file | + +## Documentation Updates + +- AGENTS.md: Document DOCUMENT phase and CLI flags +- Prompt templates: Self-documenting via frontmatter diff --git a/plans/research/standard-names/archived-v1/11-schema-and-persistence.md b/plans/research/standard-names/archived-v1/11-schema-and-persistence.md new file mode 100644 index 000000000..4604aad25 --- /dev/null +++ b/plans/research/standard-names/archived-v1/11-schema-and-persistence.md @@ -0,0 +1,521 @@ +# Feature 11: StandardName Schema & Graph Persistence + +**Status:** Pending +**Priority:** High — schema compliance and data durability +**Depends on:** None (Phase 1 is independent); Phase 2 depends on 09/10 +**Parallel with:** 09 (LLM compose), 12 (benchmark) — Phase 1 only +**Estimated complexity:** Medium + +--- + +## Problem + +StandardName has a partial LinkML schema (8 fields in `facility.yaml`) but +`graph_ops.py` writes 9 additional properties not declared in the schema. +This means: no generated Pydantic model, no schema compliance tests, no +vector index management, ad-hoc Cypher. Additionally, the pipeline never +calls `write_standard_names()` — generated names exist only in memory +during the run and are lost when it completes. + +The catalog (309 entries) also needs to be mirrored into the graph for +cross-referencing and dedup, but with provenance marking so they aren't +confused with codex-generated names. + +## Approach + +Split into three sub-features to maximize parallelism: + +- **Phase 1 (Schema Alignment)**: Finalize LinkML schema, rebuild models — + independent of other plans +- **Phase 2 (Graph Persistence — PERSIST_NODES)**: Persist validated names + to graph **before** LINK and SCORE phases can operate on them +- **Phase 3 (Catalog Mirroring)**: Mirror external catalog for + cross-referencing, link validation, and dedup + +### Revised Pipeline Architecture + +``` +EXTRACT → COMPOSE → REVIEW → VALIDATE → DOCUMENT → PERSIST_NODES → LINK → SCORE +``` + +**Critical ordering**: PERSIST_NODES comes **before** LINK and SCORE because: +- LINK creates `CROSS_REFERENCES` and `DEPENDS_ON` edges between StandardName + nodes — these nodes must exist in the graph first +- SCORE reads from and writes to graph nodes +- Without PERSIST_NODES first, both LINK and SCORE would have to work entirely + in-memory with no graph access + +### Relationship Design + +The StandardName schema uses **explicit relationship-writing code** for +multivalued self-referential relationships, not the `create_nodes()` +auto-relationship mechanism. This is because: + +1. `create_nodes()` auto-relationships only match **scalar** slot values + (one property → one target node). `cross_references` and `depends_on` + are multivalued (one source → many targets). +2. Self-referential edges need idempotency for LINK re-runs — the LINK + worker uses explicit `MERGE` with relationship properties. +3. Edge properties (link_type, created_at) require `create_relationship()`. + +**What `create_nodes()` handles** (scalar slots): +- `derived_from_dd → DERIVED_FROM → IMASNode` (scalar, auto works) +- `derived_from_signal → DERIVED_FROM → FacilitySignal` (scalar, auto works) +- `canonical_units → CANONICAL_UNITS → Unit` (scalar, auto works) + +**What requires explicit code** (multivalued slots): +- `cross_references → CROSS_REFERENCES → StandardName` (multivalued) +- `depends_on → DEPENDS_ON → StandardName` (multivalued) + +--- + +## Phase 1: Schema Alignment + +### Tasks + +1. **Add StandardNameStatus enum** + - File: `imas_codex/schemas/common.yaml` (or `facility.yaml`) + ```yaml + StandardNameStatus: + permissible_values: + composed: + description: Generated by LLM compose phase + reviewed: + description: Passed cross-model review + validated: + description: Passed grammar round-trip validation + documented: + description: Rich documentation generated + persisted: + description: Written to graph (PERSIST_NODES complete) + linked: + description: Cross-references validated and graph edges created + scored: + description: Quality scoring complete + published: + description: Published to external catalog + catalog_mirror: + description: Mirrored from external catalog (not generated) + ``` + +2. **Complete StandardName class in LinkML** + - File: `imas_codex/schemas/facility.yaml` + - Add all properties currently written by `graph_ops.py` + - Add relationship slots with proper annotations + - Add vector index annotation + - Add fulltext index annotation + + ```yaml + StandardName: + description: >- + A canonical physics quantity name following the IMAS Standard Names + grammar. Generated by the SN build pipeline or mirrored from the + external catalog. Linked to source DD paths or facility signals + via DERIVED_FROM relationships. Self-referential links via + CROSS_REFERENCES (navigational) and DEPENDS_ON (functional). + class_uri: facility:StandardName + annotations: + fulltext_index: + tag: fulltext_index + value: "standard_name_text:id,description,keywords" + attributes: + id: + identifier: true + description: The canonical standard name string (e.g., electron_temperature) + required: true + # --- Grammar decomposition --- + physical_base: + description: Root physics quantity (e.g., temperature, density) + geometric_base: + description: Geometric quantity when applicable (e.g., position, outline) + subject: + description: Species or population (e.g., electron, ion, deuterium) + component: + description: Vector/tensor component (e.g., toroidal, poloidal) + coordinate: + description: Coordinate system component + position: + description: Measurement location (e.g., magnetic_axis, plasma_boundary) + process: + description: Physical process (e.g., ohmic, bootstrap) + transformation: + description: Mathematical transformation (e.g., square_of, logarithm_of) + object: + description: Device component (e.g., flux_loop, rogowski_coil) + binary_operator: + description: Compound operator (e.g., ratio_of, product_of) + secondary_base: + description: Second quantity in binary operator compounds + # --- Metadata --- + kind: + description: "Name kind: physical, geometric, or metadata" + range: string + canonical_units: + description: SI unit string (e.g., eV, m^-3, A) + range: Unit + annotations: + relationship_type: CANONICAL_UNITS + description: + description: Rich documentation with LaTeX, equations, measurement methods + keywords: + description: Classification keywords for search + multivalued: true + range: string + tags: + description: Category tags (e.g., core_profiles, magnetics) + multivalued: true + range: string + # --- Provenance --- + source: + description: "Source type: dd, signal, or catalog" + source_path: + description: Originating DD path or signal ID + confidence: + description: Generation confidence (0.0-1.0) + range: float + provenance: + description: "Origin marker: generated, catalog_mirror" + # --- Status and lifecycle --- + status: + description: Pipeline lifecycle status + range: StandardNameStatus + required: true + claimed_at: + description: Worker coordination timestamp + range: datetime + claim_token: + description: Atomic claim verification token + # --- Vector search --- + embedding: + description: Vector embedding of description for semantic search + multivalued: true + range: float + annotations: + vector_index_name: standard_name_embedding + embedding_hash: + description: Hash of embedded text for change detection + embedded_at: + description: When embedding was last computed + range: datetime + # --- Temporal --- + created_at: + description: When the name was first created + range: datetime + documented_at: + description: When documentation was last generated + range: datetime + published_at: + description: When the name was published to catalog + range: datetime + # --- Relationships (scalar — auto-handled by create_nodes) --- + derived_from_dd: + description: Source DD path(s) this name was derived from + range: IMASNode + multivalued: true + annotations: + relationship_type: DERIVED_FROM + derived_from_signal: + description: Source signal(s) this name was derived from + range: FacilitySignal + multivalued: true + annotations: + relationship_type: DERIVED_FROM + # --- Relationships (multivalued self-ref — requires explicit code) --- + cross_references: + description: >- + Other standard names referenced in documentation (navigational). + Populated by the LINK phase after validation. Only resolved + references become graph edges — forward/broken refs are stored + as node properties (unresolved_refs, forward_refs). + Requires explicit relationship-writing code (not create_nodes + auto-relationships) because this is multivalued. + range: StandardName + multivalued: true + inlined: false + annotations: + relationship_type: CROSS_REFERENCES + depends_on: + description: >- + Standard names this quantity functionally depends on (definitional). + Distinct from cross_references: depends_on encodes physics + derivation (e.g., safety_factor depends_on plasma_current), + while cross_references are documentation links. + Requires explicit relationship-writing code (same reason). + range: StandardName + multivalued: true + inlined: false + annotations: + relationship_type: DEPENDS_ON + # --- Link resolution tracking --- + unresolved_refs: + description: >- + Cross-reference mentions that could not be resolved to existing + StandardName nodes. Stored as node property (not graph edge) + because the target does not exist. Cleared when LINK phase + resolves them in a subsequent run. + multivalued: true + range: string + forward_refs: + description: >- + Cross-reference mentions that resolve to names in the current + batch but not yet persisted. Converted to CROSS_REFERENCES + edges after PERSIST_NODES. + multivalued: true + range: string + ref_in_degree: + description: >- + Number of other standard names that reference this name + (from CROSS_REFERENCES + DEPENDS_ON relationships). + range: integer + ref_out_degree: + description: >- + Number of other standard names this name references + (from CROSS_REFERENCES + DEPENDS_ON relationships). + range: integer + # --- Score dimensions --- + score_semantic_accuracy: + description: >- + How well the name captures the physics concept (0.0-1.0). + Gating dimension: if low, composite must be low regardless + of other dimensions. + range: float + score_catalog_convention: + description: >- + Adherence to naming conventions established by the existing + 309-entry catalog (0.0-1.0). Replaces score_grammar to avoid + duplicating the VALIDATE phase. + range: float + score_units_consistency: + description: >- + Correctness of canonical units for the physics quantity + (0.0-1.0). Gating dimension alongside semantic_accuracy. + range: float + score_documentation_grounding: + description: >- + Quality and accuracy of generated documentation (0.0-1.0). + Equations, measurement methods, typical values, physics context. + range: float + score_link_quality: + description: >- + Quality of cross-references and dependency links (0.0-1.0). + Completeness, accuracy, degree of broken/unresolved refs. + range: float + score_composite: + description: >- + Gated composite score. Computed as: if min(semantic, units) < 0.3 + then 0.0; else weighted_mean(all dimensions). Unlike discovery + scoring which uses max_composite(), SN scoring gates on + semantic+units because a well-documented wrong name is worthless. + range: float + scored_at: + description: When quality scoring was last performed + range: datetime + ``` + + **Design notes:** + - `CROSS_REFERENCES` vs `DEPENDS_ON` are separate relationship types + (navigational vs functional dependency — different semantics) + - Both follow WikiPage `LINKS_TO` pattern with degree counters + - `unresolved_refs` and `forward_refs` are node properties (not edges) + because targets may not exist in the graph + - Score dimensions follow discovery patterns (CodeScoreFields, + ContentScoreFields) but use gated composite instead of max_composite() + +3. **Rebuild generated models** + - Run: `uv run build-models --force` + - Verify `imas_codex/graph/models.py` includes `StandardName` and `StandardNameStatus` + - Verify `agents/schema-reference.md` includes StandardName + +4. **Update `graph_ops.py` to use generated model** + - Import `StandardName` from generated models + - Use model for validation before graph writes + - Align property names: `source_type` → `source`, `units` → `canonical_units` + +### Acceptance Criteria +- LinkML schema declares all StandardName properties and relationships +- `uv run build-models --force` succeeds +- Generated Pydantic model includes all fields +- Schema compliance tests pass for StandardName nodes + +--- + +## Phase 2: Graph Persistence (PERSIST_NODES phase) + +**Depends on:** Plans 09 (compose output contract) and 10 (document output) + +### Design + +PERSIST_NODES writes StandardName data to the graph **before** LINK and +SCORE phases. This is a critical ordering change from earlier drafts that +had PERSIST_GRAPH at the end. + +**What PERSIST_NODES writes:** +- StandardName node with all properties (grammar fields, metadata, docs) +- Scalar relationships via `create_nodes()`: DERIVED_FROM, CANONICAL_UNITS +- Sets status to `persisted` + +**What PERSIST_NODES does NOT write:** +- `CROSS_REFERENCES` edges (that's LINK's job — Plan 14) +- `DEPENDS_ON` edges (that's LINK's job — Plan 14) +- Score properties (that's SCORE's job — Plan 12) + +### Tasks + +1. **Implement `persist_nodes_worker()`** + - File: `imas_codex/sn/workers.py` + - Reads from `state.documented` (output of DOCUMENT phase) + - Converts documented entries to `StandardName` model instances + - Calls `write_standard_names()` (refactored in Task 2) + - Sets status to `persisted` + - Tracks written count in stats + - Pattern: claim→write→release (follows discovery worker pattern) + +2. **Refactor `write_standard_names()` to use `create_nodes()`** + - File: `imas_codex/sn/graph_ops.py` + - Replace raw Cypher MERGE with `gc.create_nodes("StandardName", items)` + - Scalar relationships auto-created by `create_nodes()`: + - `derived_from_dd` → DERIVED_FROM → IMASNode + - `derived_from_signal` → DERIVED_FROM → FacilitySignal + - `canonical_units` → CANONICAL_UNITS → Unit + - **Exclude** `cross_references` and `depends_on` from `create_nodes()` + data — these are handled by LINK phase via explicit Cypher + - Handle batch sizes for large runs (50 nodes per batch) + +3. **Add explicit relationship-writing functions for LINK phase** + - File: `imas_codex/sn/graph_ops.py` + - `write_cross_references(source_id, target_ids)` — creates + `CROSS_REFERENCES` edges using `MERGE` for idempotency + - `write_depends_on(source_id, target_ids)` — creates `DEPENDS_ON` edges + - `update_degree_counters(name_id)` — recalculates `ref_in_degree` and + `ref_out_degree` from actual edge counts + - `clear_unresolved_refs(name_id, resolved_ids)` — removes entries from + `unresolved_refs` that are now resolved + - All functions use `MERGE` for idempotent re-runs + + ```python + def write_cross_references( + gc: GraphClient, source_id: str, target_ids: list[str] + ) -> int: + """Create CROSS_REFERENCES edges. Returns count of edges created.""" + return gc.query(""" + UNWIND $targets AS target_id + MATCH (src:StandardName {id: $source_id}) + MATCH (tgt:StandardName {id: target_id}) + MERGE (src)-[r:CROSS_REFERENCES]->(tgt) + ON CREATE SET r.created_at = datetime() + RETURN count(r) AS created + """, source_id=source_id, targets=target_ids) + ``` + +4. **Add PERSIST_NODES phase to pipeline** + - File: `imas_codex/sn/pipeline.py` + - Between DOCUMENT and LINK: + ```python + WorkerSpec( + "persist_nodes", + "persist_nodes_phase", + persist_nodes_worker, + depends_on=["document_phase"], + ), + ``` + +5. **Add `--no-persist` CLI flag** + - File: `imas_codex/cli/sn.py` + - Skips graph write (for testing/dry-run scenarios) + - Dry-run mode already implies no persist + - Also skips LINK and SCORE (they need persisted nodes) + +### Acceptance Criteria +- Pipeline writes results to graph after DOCUMENT phase +- StandardName nodes have correct properties +- Scalar DERIVED_FROM edges link to IMASNode or FacilitySignal +- Multivalued relationships NOT created by PERSIST_NODES (LINK's job) +- `--no-persist` flag prevents graph writes and skips LINK/SCORE + +--- + +## Phase 3: Catalog Mirroring + +Mirror the external 309-entry catalog into the graph for cross-referencing, +dedup, and link checking. Mark provenance clearly. + +### Tasks + +1. **Add `sn import-catalog` CLI command** + - File: `imas_codex/cli/sn.py` + - Loads catalog entries (via frozen snapshot or imas-sn API) + - Creates StandardName nodes with `provenance='catalog_mirror'`, + `status='catalog_mirror'` + - Idempotent: MERGE by name, don't overwrite generated entries + - Options: `--source {snapshot,api}`, `--dry-run` + +2. **Create catalog snapshot fixture** + - File: `imas_codex/sn/catalog_snapshot.py` (or JSON/YAML data file) + - Export: name, kind, unit, tags, description (summary only) + - Used for: tests, offline/CI, initial import + - Updated periodically from catalog repo + +3. **Update dedup queries** + - `get_existing_standard_names()` should return both generated and + mirrored names + - Review/link phases should distinguish provenance when reporting + +### Acceptance Criteria +- `sn import-catalog` imports 309 names into graph +- Mirrored entries have `provenance='catalog_mirror'` +- Dedup works against both generated and mirrored names +- Publish phase excludes mirrored entries + +--- + +## Phase 4: Tests + +### Tasks + +1. **Schema compliance tests** + - StandardName nodes match LinkML declaration + - All properties have correct types + - Relationships are properly annotated + - CROSS_REFERENCES and DEPENDS_ON declared with correct annotations + +2. **Persistence round-trip tests** + - Write → read → verify all fields preserved + - DERIVED_FROM relationships created correctly + - Verify CROSS_REFERENCES NOT created by persist_nodes_worker + +3. **Catalog mirror tests** + - Import creates correct nodes with provenance + - Idempotent reimport doesn't overwrite + +4. **Explicit relationship tests** + - `write_cross_references()` creates edges correctly + - `write_depends_on()` creates edges correctly + - `update_degree_counters()` computes correct in/out degree + - Idempotent re-runs don't create duplicate edges + +### Acceptance Criteria +- All schema compliance tests pass +- Graph round-trip preserves all data +- Relationship functions are idempotent + +--- + +## Files Modified / Created + +| File | Change | +|------|--------| +| `imas_codex/schemas/facility.yaml` | Complete StandardName class with all relationships and score dimensions | +| `imas_codex/schemas/common.yaml` | Add StandardNameStatus enum (expanded with persisted/linked/scored) | +| `imas_codex/sn/graph_ops.py` | Refactor to use create_nodes(); add explicit relationship functions | +| `imas_codex/sn/workers.py` | Add persist_nodes_worker | +| `imas_codex/sn/pipeline.py` | Add PERSIST_NODES phase (before LINK/SCORE) | +| `imas_codex/cli/sn.py` | Add --no-persist, sn import-catalog | +| `imas_codex/sn/catalog_snapshot.py` | New: frozen catalog data | +| `tests/sn/test_schema_compliance.py` | New: schema tests | +| `tests/sn/test_persistence.py` | New: round-trip + relationship tests | + +## Documentation Updates + +- AGENTS.md: Document `sn import-catalog` command and schema changes +- AGENTS.md: Document PERSIST_NODES ordering requirement +- Schema reference: auto-updated by `build-models` diff --git a/plans/research/standard-names/archived-v1/12-benchmark-enhancement.md b/plans/research/standard-names/archived-v1/12-benchmark-enhancement.md new file mode 100644 index 000000000..ad082bebb --- /dev/null +++ b/plans/research/standard-names/archived-v1/12-benchmark-enhancement.md @@ -0,0 +1,448 @@ +# Feature 12: Quality Scoring & Benchmark + +**Status:** Pending +**Priority:** High — scoring grounds quality; benchmark drives model decisions +**Depends on:** Feature 11 Phase 2 (PERSIST_NODES), Feature 14 (LINK) +**Parallel with:** 14 (linker Phase 1) — scorer and linker can be developed simultaneously +**Estimated complexity:** High + +--- + +## Problem + +Two interrelated gaps: + +1. **No quality scoring in the build pipeline.** Generated names are either + accepted or rejected by the binary REVIEW phase, but there's no continuous + quality metric. Discovery pipelines (code, wiki, path) all have rich + scoring with calibrated dimensions — standard names need the same. + +2. **Benchmark is entangled with review.** The current benchmark + (`sn/benchmark.py`) contains a review worker that should be reusable + independently. The benchmark should be a thin CLI that runs the build + pipeline with different models and compares results — it should import + the scoring module, not own it. + +## Approach + +**Two deliverables:** + +- **Scorer module** (`sn/scorer.py`): Reusable quality scoring for standard + names, usable by both the build pipeline (as a SCORE phase) and the + benchmark (for comparison). Follows discovery scoring patterns. +- **Benchmark CLI** (`sn benchmark`): Thin orchestration that runs the build + pipeline with different models, scores results, and compares. Imports + from scorer — doesn't implement scoring itself. + +### Score Architecture (follows discovery patterns) + +The discovery pipeline's scoring system provides the template: + +| Discovery Pattern | SN Equivalent | +|-------------------|---------------| +| `CodeScoreFields(BaseModel)` | `SNScoreFields(BaseModel)` | +| `max_composite()` | `gated_composite()` (semantic+units gate) | +| `sample_code_dimension_calibration()` | `sample_sn_calibration()` | +| `score_worker()` claim→score→persist | `score_worker()` claim→score→persist | +| `StageDisplaySpec` progress | `StageDisplaySpec` progress | + +**Key difference from discovery scoring:** Discovery uses `max_composite()` +(composite = max of all dimensions). SN scoring uses a **gated composite** +where `score_semantic_accuracy` and `score_units_consistency` are gate +dimensions — if either is below threshold (0.3), the composite is forced +to 0.0 regardless of how good documentation or conventions are. A +well-documented wrong name is worthless. + +### Score Dimensions + +| Dimension | What It Measures | Gate? | +|-----------|-----------------|-------| +| `score_semantic_accuracy` | Does the name capture the physics concept? | Yes | +| `score_units_consistency` | Are the canonical units correct? | Yes | +| `score_catalog_convention` | Does naming match catalog patterns? | No | +| `score_documentation_grounding` | Documentation quality and accuracy | No | +| `score_link_quality` | Cross-reference completeness and accuracy | No | + +**NOT included:** `score_grammar` — duplicates the VALIDATE phase which +already does deterministic grammar round-trip validation. + +### Pipeline Position + +``` +EXTRACT → COMPOSE → REVIEW → VALIDATE → DOCUMENT → PERSIST_NODES → LINK → SCORE +``` + +SCORE is the final phase, running after LINK because `score_link_quality` +depends on link resolution results. + +--- + +## Phase 1: Score Model & Composite Function + +### Tasks + +1. **Create `SNScoreFields` base model** + - File: `imas_codex/sn/scorer.py` + ```python + from pydantic import BaseModel, Field + + class SNScoreFields(BaseModel): + """Score dimensions for standard name quality assessment.""" + score_semantic_accuracy: float = Field( + ge=0.0, le=1.0, + description="How well the name captures the physics concept" + ) + score_catalog_convention: float = Field( + ge=0.0, le=1.0, + description="Adherence to catalog naming conventions" + ) + score_units_consistency: float = Field( + ge=0.0, le=1.0, + description="Correctness of canonical units" + ) + score_documentation_grounding: float = Field( + ge=0.0, le=1.0, + description="Quality of generated documentation" + ) + score_link_quality: float = Field( + ge=0.0, le=1.0, + description="Cross-reference completeness and accuracy" + ) + + def get_score_dict(self) -> dict[str, float]: + """Return dict of dimension_name → score for graph persistence.""" + return self.model_dump() + ``` + +2. **Implement gated composite function** + - File: `imas_codex/sn/scorer.py` + ```python + GATE_DIMENSIONS = {"score_semantic_accuracy", "score_units_consistency"} + GATE_THRESHOLD = 0.3 + DIMENSION_WEIGHTS = { + "score_semantic_accuracy": 0.30, + "score_catalog_convention": 0.15, + "score_units_consistency": 0.25, + "score_documentation_grounding": 0.20, + "score_link_quality": 0.10, + } + + def gated_composite(scores: SNScoreFields) -> float: + """Compute gated composite score. + + If any gate dimension is below threshold, composite is 0.0. + Otherwise, weighted mean of all dimensions. + """ + score_dict = scores.get_score_dict() + for gate_dim in GATE_DIMENSIONS: + if score_dict.get(gate_dim, 0.0) < GATE_THRESHOLD: + return 0.0 + return sum( + score_dict[dim] * weight + for dim, weight in DIMENSION_WEIGHTS.items() + if dim in score_dict + ) + ``` + +3. **Create scoring LLM prompt** + - File: `imas_codex/llm/prompts/sn/score.md` + - System message (static, cacheable): + - Role: fusion physics quality assessor + - Rubric for each dimension with examples at 5 levels (0.0, 0.25, 0.5, 0.75, 1.0) + - Calibration examples from catalog at each quality tier: + - Outstanding (0.9+): `electron_temperature` — full LaTeX, typical values, measurement methods + - Good (0.7-0.9): `plasma_current` — solid physics context, equations + - Adequate (0.5-0.7): `toroidal_magnetic_field` — correct but sparse docs + - Poor (0.25-0.5): a name with wrong units or misleading semantics + - Failing (<0.25): a name that doesn't match its physics concept + - Gate rule explanation: semantic+units must be ≥0.3 + - User message (dynamic): + - The standard name entry (all fields) + - Its documentation + - Its link resolution status (resolved/unresolved counts) + - Related catalog entries for comparison + - Response model: `SNScoreFields` + +### Acceptance Criteria +- `SNScoreFields` validates all dimensions are 0.0-1.0 +- `gated_composite()` returns 0.0 when gates fail +- `gated_composite()` returns weighted mean when gates pass +- Prompt includes calibration examples at 5 quality levels + +--- + +## Phase 2: Dynamic Calibration + +### Design + +Follow the discovery scoring calibration pattern: periodically sample +already-scored StandardName nodes from the graph at 5 quality levels per +dimension, inject these as few-shot examples into the scoring prompt. +This keeps scoring consistent across runs and models. + +### Tasks + +1. **Implement calibration sampling** + - File: `imas_codex/sn/scorer.py` + ```python + _calibration_cache: dict[str, Any] = {} + _calibration_ttl: float = 300.0 # 5-minute TTL + + def sample_sn_calibration(gc: GraphClient) -> dict[str, list[dict]]: + """Sample scored StandardName nodes at 5 levels per dimension. + + Returns: {dimension_name: [{name, score, snippet}, ...]} + Follows discovery/code/scorer.py pattern. + """ + now = time.time() + if _calibration_cache.get("timestamp", 0) + _calibration_ttl > now: + return _calibration_cache.get("data", {}) + + calibration = {} + for dim in SNScoreFields.model_fields: + samples = gc.query(f""" + MATCH (sn:StandardName) + WHERE sn.{dim} IS NOT NULL + WITH sn, sn.{dim} AS score + ORDER BY score + WITH collect({{name: sn.id, score: score, + desc: substring(sn.description, 0, 200)}}) AS all_items + RETURN [ + all_items[0], + all_items[toInteger(size(all_items)*0.25)], + all_items[toInteger(size(all_items)*0.5)], + all_items[toInteger(size(all_items)*0.75)], + all_items[size(all_items)-1] + ] AS levels + """) + calibration[dim] = samples[0]["levels"] if samples else [] + + _calibration_cache["data"] = calibration + _calibration_cache["timestamp"] = now + return calibration + ``` + +2. **Integrate calibration into prompt** + - Scoring prompt includes calibration examples when available + - Falls back to static examples for first run (no scored nodes yet) + - Calibration refreshed every 5 minutes (300s TTL) + +### Acceptance Criteria +- Calibration samples 5 levels per dimension +- TTL cache prevents excessive graph queries +- Graceful fallback when no scored nodes exist + +--- + +## Phase 3: SCORE Pipeline Worker + +### Tasks + +1. **Implement `score_worker()`** + - File: `imas_codex/sn/workers.py` + - Pattern: claim→score→persist→release (follows discovery workers) + - Steps: + 1. Claim batch of `status='linked'` (or `status='persisted'`) nodes + 2. Rebuild calibration examples (periodically, via TTL cache) + 3. For each name: build scoring prompt with calibration + 4. Call LLM for structured output → `SNScoreFields` + 5. Compute `gated_composite()` + 6. Persist scores to graph (SET all score dimensions + composite + scored_at) + 7. Update status to `scored` + 8. Release claim + - Batch size: 5-10 names per LLM call + - Uses `get_model("language")` (scoring is classification, not generation) + - Cost tracking in `state.score_stats` + +2. **Add SCORE phase to pipeline** + - File: `imas_codex/sn/pipeline.py` + ```python + WorkerSpec( + "score", + "score_phase", + score_worker, + depends_on=["link_phase"], + enabled=not state.skip_score, + ), + ``` + +3. **Add state fields** + - File: `imas_codex/sn/state.py` + - `score_stats: WorkerStats` — phase tracking + - `score_phase: PipelinePhase` — supervision + - `skip_score: bool = False` — CLI control + - `score_model: str | None = None` — model override + +4. **Add progress display** + - File: `imas_codex/sn/progress.py` + - SCORE stage: shows scored count, average composite, cost + - Shows gate failure count (how many names gated to 0.0) + +5. **Add CLI flags** + - File: `imas_codex/cli/sn.py` + - `--skip-score` — bypass scoring phase + - `--score-model` — override model for scoring + - `sn build` summary includes score statistics: + ``` + Scores: avg=0.72, gated=3/87, top=electron_temperature (0.95) + ``` + +### Acceptance Criteria +- SCORE phase runs after LINK in `sn build` +- Scores persisted to graph with all 5 dimensions + composite +- Gate failures produce composite=0.0 +- Cost tracking included in pipeline summary +- `--skip-score` bypasses the phase + +--- + +## Phase 4: Benchmark Enhancement + +### Design + +The benchmark becomes a thin orchestration CLI that: +1. Runs `sn build` with different `--compose-model` values +2. Scores results using the shared `sn/scorer.py` module +3. Compares across models +4. Reports which model produces the best names + +The benchmark does NOT implement its own scoring — it reuses the scorer. + +### Tasks + +1. **Refactor benchmark to use shared scorer** + - File: `imas_codex/sn/benchmark.py` + - Remove internal scoring logic + - Import `SNScoreFields`, `gated_composite`, `sample_sn_calibration` + from `sn/scorer.py` + - `_run_model()` now: + 1. Runs pipeline (compose + review + validate) + 2. Calls scorer for each result → `SNScoreFields` + 3. Computes `gated_composite()` for each + 4. Returns model-level aggregate stats + +2. **Add model comparison report** + - File: `imas_codex/sn/benchmark.py` + - Compare models on: + - Average composite score + - Gate failure rate + - Per-dimension breakdown + - Cost per name + - Rich table output: + ``` + Model Comparison (31 reference items): + ┌────────────────────────┬────────┬────────┬────────┬────────┐ + │ Model │ Avg │ Gated │ Cost │ Sem. │ + ├────────────────────────┼────────┼────────┼────────┼────────┤ + │ openrouter/claude-4-so │ 0.82 │ 1/31 │ $0.45 │ 0.89 │ + │ openrouter/gpt-4.1 │ 0.76 │ 3/31 │ $0.32 │ 0.81 │ + │ openrouter/gemini-2.5 │ 0.71 │ 5/31 │ $0.28 │ 0.75 │ + └────────────────────────┴────────┴────────┴────────┴────────┘ + ``` + +3. **Update reference set management** + - Keep the existing 31-entry reference set + - Add scored reference entries (name + expected score range) + - Benchmark validates that scorer produces consistent scores for + reference entries across models + +4. **Update CLI** + - File: `imas_codex/cli/sn.py` + - `sn benchmark` remains unchanged interface-wise + - Internally uses shared scorer instead of ad-hoc review + - `sn benchmark --compare` shows side-by-side model comparison + - `sn benchmark --score-only` scores existing results without re-running + +### Acceptance Criteria +- Benchmark imports all scoring from `sn/scorer.py` +- No duplicate scoring logic in benchmark +- Model comparison report includes all 5 dimensions + composite +- Reference set includes score expectations +- `sn benchmark` works independently of `sn build` pipeline + +--- + +## Phase 5: Standalone `sn score` Command + +### Tasks + +1. **Add `sn score` CLI command** + - File: `imas_codex/cli/sn.py` + - Score or re-score existing StandardName nodes in the graph + - Modes: + - `sn score` — score all unscored names + - `sn score --all` — re-score everything + - `sn score --name electron_temperature` — score specific name + - Uses same scorer module as pipeline and benchmark + - Useful for: re-scoring after model changes, scoring catalog mirrors + +### Acceptance Criteria +- `sn score` scores unscored names +- Uses shared scorer module +- Results persisted to graph + +--- + +## Phase 6: Tests + +### Tasks + +1. **Test score model and composite** + - File: `tests/sn/test_scorer.py` + - Test `SNScoreFields` validation + - Test `gated_composite()`: + - Gates pass → weighted mean + - Semantic gate fails → 0.0 + - Units gate fails → 0.0 + - Both gates fail → 0.0 + - Edge cases: exactly at threshold (0.3) + - Test `get_score_dict()` output format + +2. **Test calibration** + - File: `tests/sn/test_scorer.py` + - Test `sample_sn_calibration()` with mocked graph + - Test TTL cache behavior + - Test fallback when no scored nodes exist + +3. **Test score worker** + - File: `tests/sn/test_score_worker.py` + - Mock LLM returning `SNScoreFields` + - Verify scores persisted to graph + - Verify status transitions + - Test batch processing + +4. **Test benchmark model comparison** + - File: `tests/sn/test_benchmark.py` + - Test comparison report generation + - Test reference set scoring consistency + - Verify benchmark imports from shared scorer + +### Acceptance Criteria +- All tests pass with mocked LLM and graph +- Gated composite tested for all gate combinations +- Calibration tested with TTL expiry +- Benchmark tested independently of build pipeline + +--- + +## Files Modified / Created + +| File | Change | +|------|--------| +| `imas_codex/sn/scorer.py` | NEW: SNScoreFields, gated_composite, calibration, score functions | +| `imas_codex/sn/workers.py` | Add score_worker() | +| `imas_codex/sn/pipeline.py` | Add SCORE WorkerSpec | +| `imas_codex/sn/state.py` | Add score_stats, score_phase, skip_score | +| `imas_codex/sn/progress.py` | Add score stage display | +| `imas_codex/sn/benchmark.py` | Refactor to use shared scorer | +| `imas_codex/cli/sn.py` | Add --skip-score, --score-model, sn score, sn benchmark --compare | +| `imas_codex/llm/prompts/sn/score.md` | NEW: scoring prompt template | +| `tests/sn/test_scorer.py` | NEW: scorer unit tests | +| `tests/sn/test_score_worker.py` | NEW: worker tests | +| `tests/sn/test_benchmark.py` | Updated: uses shared scorer | + +## Documentation Updates + +- AGENTS.md: Document SCORE phase, `sn score` command, scoring dimensions +- AGENTS.md: Document gated composite formula +- AGENTS.md: Document benchmark model comparison workflow diff --git a/plans/research/standard-names/archived-v1/13-integration-testing.md b/plans/research/standard-names/archived-v1/13-integration-testing.md new file mode 100644 index 000000000..e0ec52b97 --- /dev/null +++ b/plans/research/standard-names/archived-v1/13-integration-testing.md @@ -0,0 +1,257 @@ +# Feature 13: Integration Testing & CI + +**Status:** Pending +**Priority:** Medium — validates all other plans work together +**Depends on:** All other plans (09-12, 14) — at least Phase 1 of each +**Parallel with:** None — this is the final validation layer +**Estimated complexity:** Medium + +--- + +## Problem + +The SN pipeline lacks end-to-end tests that exercise the full 8-phase flow. +Individual phases are tested in their respective plans (09-12, 14), but +integration between phases — data flow, state transitions, error propagation, +and the complete `sn build` CLI experience — is not covered. + +The existing test suite (`tests/sn/`) tests grammar, composition, and the +MCP server, but nothing for the pipeline orchestration, graph persistence, +link validation, or quality scoring. + +## Approach + +Three test layers: + +1. **Pipeline integration tests** — full 8-phase flow with mocked LLM +2. **CLI integration tests** — `sn build`, `sn link`, `sn score`, `sn status` +3. **CI workflow updates** — ensure SN tests run in CI + +### Pipeline Architecture (8 phases) + +``` +EXTRACT → COMPOSE → REVIEW → VALIDATE → DOCUMENT → PERSIST_NODES → LINK → SCORE +``` + +Each phase produces output consumed by the next. Integration tests verify +the full chain without mocking intermediate state. + +--- + +## Phase 1: Pipeline Integration Tests + +### Tasks + +1. **Create pipeline test fixtures** + - File: `tests/sn/conftest.py` + - `mock_dd_paths()` — fixture returning DD path metadata for EXTRACT + - `mock_llm_compose()` — returns valid compose output (grammar fields) + - `mock_llm_review()` — returns review verdicts (accept/revise/reject) + - `mock_llm_document()` — returns rich documentation with cross-refs + - `mock_llm_score()` — returns SNScoreFields + - `mock_graph_client()` — in-memory graph stub for PERSIST/LINK/SCORE + - Each fixture returns data matching the exact Pydantic models used + by workers + +2. **Test full pipeline flow** + - File: `tests/sn/test_pipeline_integration.py` + - Test: `test_full_pipeline_dd_source` + - Input: 5 DD paths from equilibrium IDS + - Expected: all 8 phases execute in order + - Verify: state transitions (extracted→composed→reviewed→validated→ + documented→persisted→linked→scored) + - Verify: data flows between phases (compose output → review input) + - Verify: total_cost accumulates across LLM phases + - Mock: all LLM calls, graph client + +3. **Test phase skipping** + - Test: `test_skip_document_phase` + - `--skip-document` → pipeline skips DOCUMENT, PERSIST_NODES still runs + (with empty docs) + - Test: `test_skip_link_phase` + - `--skip-link` → pipeline skips LINK, SCORE still runs (score_link_quality=0) + - Test: `test_skip_score_phase` + - `--skip-score` → pipeline skips SCORE, results still persisted + - Test: `test_no_persist_mode` + - `--no-persist` → skips PERSIST_NODES, LINK, and SCORE + - Test: `test_dry_run_mode` + - `--dry-run` → no LLM calls, no graph writes, shows plan only + +4. **Test error propagation** + - Test: `test_compose_failure_stops_pipeline` + - LLM error in COMPOSE → pipeline reports error, no downstream phases run + - Test: `test_review_rejection_reduces_count` + - REVIEW rejects 2/5 names → VALIDATE receives 3 names + - Test: `test_validate_failure_reduces_count` + - VALIDATE fails 1/3 names → DOCUMENT receives 2 names + - Test: `test_persist_failure_stops_link_score` + - Graph error in PERSIST_NODES → LINK and SCORE don't run + +5. **Test pipeline phase dependencies** + - Test: `test_phase_ordering` + - Verify WorkerSpec depends_on chain: + compose→review→validate→document→persist→link→score + - Test: `test_parallel_phases_are_independent` + - Phases without dependencies can run concurrently (none currently, but + architecture supports it) + +### Acceptance Criteria +- Full pipeline test exercises all 8 phases end-to-end +- Phase skipping tested for all skip flags +- Error propagation verified for each phase boundary +- No graph or MCP dependency (all mocked) + +--- + +## Phase 2: CLI Integration Tests + +### Tasks + +1. **Test `sn build` command** + - File: `tests/sn/test_cli_integration.py` + - Test: `test_sn_build_basic` + - `sn build --source dd --ids equilibrium --dry-run` + - Verify: exits 0, shows pipeline plan + - Test: `test_sn_build_with_all_flags` + - `sn build --source dd --ids equilibrium --compose-model X --skip-document --no-persist` + - Verify: flags propagated to state correctly + - Test: `test_sn_build_summary_output` + - Verify: summary includes phase stats, costs, timing + +2. **Test `sn link` command** + - File: `tests/sn/test_cli_integration.py` + - Test: `test_sn_link_basic` + - `sn link` — re-links names with unresolved references + - Test: `test_sn_link_all` + - `sn link --all` — re-validates all existing edges + - Test: `test_sn_link_convergence` + - Run link twice → fewer unresolved on second run + +3. **Test `sn score` command** + - File: `tests/sn/test_cli_integration.py` + - Test: `test_sn_score_basic` + - `sn score` — scores unscored names + - Test: `test_sn_score_specific` + - `sn score --name electron_temperature` — scores one name + +4. **Test `sn status` command** + - File: `tests/sn/test_cli_integration.py` + - Test: `test_sn_status_includes_link_health` + - Verify link health section in output + - Test: `test_sn_status_includes_score_summary` + - Verify score statistics in output + +5. **Test `sn benchmark` command** + - File: `tests/sn/test_cli_integration.py` + - Test: `test_sn_benchmark_uses_shared_scorer` + - Verify benchmark imports from `sn/scorer.py` + - Test: `test_sn_benchmark_comparison` + - `sn benchmark --compare` shows model comparison table + +### Acceptance Criteria +- All CLI commands exit cleanly +- Flag propagation verified +- Output format validated + +--- + +## Phase 3: Graph Integration Tests + +### Tasks + +1. **Test StandardName graph round-trip** + - File: `tests/sn/test_graph_integration.py` + - Test: `test_persist_and_read_back` + - Write StandardName → read back → verify all fields + - Test: `test_cross_references_edge_creation` + - Persist two names → create CROSS_REFERENCES edge → verify + - Test: `test_depends_on_edge_creation` + - Persist two names → create DEPENDS_ON edge → verify + - Test: `test_degree_counter_accuracy` + - Create multiple edges → verify ref_in_degree, ref_out_degree + - Test: `test_unresolved_refs_property` + - Persist name with unresolved → verify property stored + - Later persist target → re-link → verify edge created and property cleared + +2. **Test catalog mirroring** + - File: `tests/sn/test_graph_integration.py` + - Test: `test_import_catalog_creates_nodes` + - Test: `test_import_catalog_idempotent` + - Test: `test_generated_vs_mirrored_provenance` + +3. **Test schema compliance** + - File: `tests/sn/test_schema_compliance.py` + - Verify StandardName nodes comply with LinkML schema + - Verify all relationship types are declared + - Verify score dimensions match schema declaration + - Verify StandardNameStatus enum values match code usage + +### Acceptance Criteria +- Graph round-trip preserves all data +- Edge creation is idempotent +- Degree counters accurate after edge modifications +- Schema compliance passes + +--- + +## Phase 4: CI Configuration + +### Tasks + +1. **Update test workflow** + - File: `.github/workflows/test.yml` (or equivalent) + - Ensure `tests/sn/` runs in CI + - SN tests should not require: + - Live graph connection (use mocks/fixtures) + - MCP server running + - API keys (mock LLM calls) + - SN tests may optionally test against graph (mark with `@pytest.mark.graph`) + +2. **Add SN benchmark CI job (optional)** + - Only on manual trigger or tag push + - Runs benchmark with reference set + - Reports scores in CI summary + +### Acceptance Criteria +- SN tests run in CI without external dependencies +- Graph tests can be skipped with marker +- CI reports SN test results clearly + +--- + +## Phase 5: Test Coverage Targets + +### Coverage Goals + +| Module | Target | What to Test | +|--------|--------|-------------| +| `sn/pipeline.py` | 90% | Phase ordering, skip logic, error propagation | +| `sn/workers.py` | 85% | All 8 workers with mocked deps | +| `sn/scorer.py` | 95% | Composite function, calibration, dimensions | +| `sn/linker.py` | 95% | Resolution engine, classification, cycles | +| `sn/graph_ops.py` | 80% | CRUD, relationships, degree counters | +| `sn/state.py` | 90% | State transitions, stats accumulation | +| `sn/models.py` | 95% | Pydantic validation, serialization | +| `cli/sn.py` | 75% | CLI flag parsing, output format | + +### Acceptance Criteria +- No module below 75% coverage +- Critical modules (scorer, linker, models) at 95%+ +- Pipeline integration tests cover all 8 phases + +--- + +## Files Modified / Created + +| File | Change | +|------|--------| +| `tests/sn/conftest.py` | Pipeline fixtures, mock LLM/graph | +| `tests/sn/test_pipeline_integration.py` | NEW: 8-phase pipeline tests | +| `tests/sn/test_cli_integration.py` | NEW: CLI command tests | +| `tests/sn/test_graph_integration.py` | NEW: graph round-trip tests | +| `tests/sn/test_schema_compliance.py` | NEW: schema compliance tests | +| `.github/workflows/test.yml` | Add SN test job | + +## Documentation Updates + +- No external doc changes — tests are self-documenting diff --git a/plans/research/standard-names/archived-v1/14-link-validation.md b/plans/research/standard-names/archived-v1/14-link-validation.md new file mode 100644 index 000000000..786d4e586 --- /dev/null +++ b/plans/research/standard-names/archived-v1/14-link-validation.md @@ -0,0 +1,337 @@ +# Feature 14: Link Validation & Graph Mirroring + +**Status:** Pending +**Priority:** High — links are what makes the catalog navigable +**Depends on:** Feature 11 Phase 2 (PERSIST_NODES must exist) +**Parallel with:** 12 (scorer — can be developed simultaneously) +**Estimated complexity:** Medium + +--- + +## Problem + +The SN pipeline generates documentation with cross-reference mentions +("see also electron_density") and dependency relationships ("derived from +plasma_current"), but these are never validated or written to the graph. +The existing catalog has 810 forward-reference warnings. Names without +validated links have limited navigability and discoverability. + +Link validation is fundamentally different from documentation generation: +- **DOCUMENT** (Plan 10) is LLM-based, expensive, generates text +- **LINK** is pure Python, cheap, validates references and writes edges +- **LINK** must run after PERSIST_NODES (Plan 11) because it creates + graph edges between existing nodes +- **LINK** needs multiple runs because forward references can only be + resolved when both source and target names exist + +## Approach + +A separate LINK worker in the build pipeline, plus a standalone `sn link` +CLI command for iterative re-runs. The LINK phase: + +1. Reads `cross_reference_mentions` and `dependency_mentions` from + documented names (output of DOCUMENT phase, persisted in graph) +2. Resolves each mention against existing StandardName nodes in the graph +3. For resolved refs: creates `CROSS_REFERENCES` or `DEPENDS_ON` edges +4. For unresolved refs: stores as `unresolved_refs` node property +5. Updates `ref_in_degree` and `ref_out_degree` counters + +### Pipeline Position + +``` +EXTRACT → COMPOSE → REVIEW → VALIDATE → DOCUMENT → PERSIST_NODES → LINK → SCORE +``` + +LINK runs after PERSIST_NODES (nodes must exist to create edges) and before +SCORE (score_link_quality depends on link resolution results). + +### Relationship Types + +Two separate relationship types with different semantics: + +| Relationship | Semantics | Example | +|-------------|-----------|---------| +| `CROSS_REFERENCES` | Navigational / "see also" | electron_temperature → ion_temperature | +| `DEPENDS_ON` | Functional / physics dependency | safety_factor → plasma_current | + +Both are `(StandardName)-[:REL]->(StandardName)` self-referential edges. +This follows the `WikiPage LINKS_TO WikiPage` pattern in `facility.yaml`. + +### Forward Reference Strategy + +Forward references (names that reference other names not yet in the graph) +cannot become graph edges because the target node doesn't exist. Strategy: + +1. **During `sn build`**: store unresolved mentions as `unresolved_refs` + node property (string list) +2. **During `sn link`** (standalone re-run): re-check `unresolved_refs` + against current graph, convert resolved ones to edges +3. **Convergence**: after multiple `sn build` runs generating different + name batches, `sn link` resolves accumulated forward refs + +This matches the user's requirement that LINK "will likely require +multiple runs as we can only link documents when the source and targets +exist." + +--- + +## Phase 1: Link Resolution Engine + +### Design + +Pure Python module that resolves cross-reference mentions to graph edges. +No LLM calls — this is deterministic string matching against the graph. + +### Tasks + +1. **Create link resolution module** + - File: `imas_codex/sn/linker.py` + - Core function: `resolve_references(mentions, known_names) -> LinkResolution` + + ```python + class LinkResolution(BaseModel): + """Result of resolving a single name's references.""" + source_id: str + resolved_cross_refs: list[str] # Matched to existing StandardName + resolved_dependencies: list[str] # Matched to existing StandardName + unresolved: list[str] # No match found + stale: list[str] # Previously unresolved, now resolvable + + class LinkBatchResult(BaseModel): + """Result of processing a batch of names.""" + total_mentions: int + resolved: int + unresolved: int + stale_resolved: int # Previously unresolved, now resolved + edges_created: int + circular_refs: list[tuple[str, str]] # (A→B, B→A) cycles + resolutions: list[LinkResolution] + ``` + +2. **Implement reference classification** + - File: `imas_codex/sn/linker.py` + - Classify each mention from DOCUMENT phase output: + - `cross_reference_mentions` → candidate `CROSS_REFERENCES` edges + - `dependency_mentions` → candidate `DEPENDS_ON` edges + - Resolution logic: + 1. Load known names: `MATCH (sn:StandardName) RETURN sn.id` + 2. For each mention, check if it exists in known names + 3. Resolved: queue for edge creation + 4. Unresolved: add to `unresolved_refs` property + - Circular reference detection: + - Build directed graph of resolved dependencies + - Detect cycles using DFS + - Log warnings for cycles (don't block — circular deps happen in physics) + +3. **Implement graph edge writing** + - File: `imas_codex/sn/graph_ops.py` + - Uses explicit Cypher (not `create_nodes()`) because: + - Multivalued self-referential edges + - Need `MERGE` for idempotent re-runs + - Need relationship timestamps + - Functions (defined in Plan 11, implemented here): + - `write_cross_references(gc, source_id, target_ids)` + - `write_depends_on(gc, source_id, target_ids)` + - `update_degree_counters(gc, name_id)` + - `clear_resolved_refs(gc, name_id, resolved_ids)` + - `get_unresolved_names(gc, limit) -> list[dict]` — for re-run mode + + ```python + def get_unresolved_names(gc: GraphClient, limit: int = 100) -> list[dict]: + """Get names with unresolved references for re-linking.""" + return list(gc.query(""" + MATCH (sn:StandardName) + WHERE size(sn.unresolved_refs) > 0 + RETURN sn.id AS id, sn.unresolved_refs AS unresolved + ORDER BY size(sn.unresolved_refs) DESC + LIMIT $limit + """, limit=limit)) + + def update_degree_counters(gc: GraphClient, name_ids: list[str]) -> int: + """Recalculate ref_in_degree and ref_out_degree from actual edges.""" + return gc.query(""" + UNWIND $ids AS name_id + MATCH (sn:StandardName {id: name_id}) + OPTIONAL MATCH (sn)-[out:CROSS_REFERENCES|DEPENDS_ON]->() + OPTIONAL MATCH ()-[inc:CROSS_REFERENCES|DEPENDS_ON]->(sn) + WITH sn, count(DISTINCT out) AS out_deg, count(DISTINCT inc) AS in_deg + SET sn.ref_out_degree = out_deg, sn.ref_in_degree = in_deg + RETURN count(sn) AS updated + """, ids=name_ids) + ``` + +### Acceptance Criteria +- `resolve_references()` correctly classifies mentions +- Resolved refs create graph edges +- Unresolved refs stored as node properties +- Circular references detected and logged +- All functions are idempotent for re-runs + +--- + +## Phase 2: LINK Pipeline Worker + +### Tasks + +1. **Implement `link_worker()`** + - File: `imas_codex/sn/workers.py` + - Runs after PERSIST_NODES in the pipeline + - Pattern: claim→process→persist→release (follows discovery workers) + - Steps: + 1. Load all known StandardName IDs from graph + 2. For each persisted name in current batch: + a. Read `cross_reference_mentions` and `dependency_mentions` + b. Resolve against known names + c. Write resolved edges + d. Store unresolved as node property + e. Update degree counters + 3. Report statistics + - No LLM calls — pure graph operations + - Fast: should process hundreds of names per second + +2. **Add LINK phase to pipeline** + - File: `imas_codex/sn/pipeline.py` + ```python + WorkerSpec( + "link", + "link_phase", + link_worker, + depends_on=["persist_nodes_phase"], + enabled=not state.skip_link, + ), + ``` + +3. **Add state fields** + - File: `imas_codex/sn/state.py` + - `link_stats: WorkerStats` — phase tracking + - `link_phase: PipelinePhase` — supervision + - `skip_link: bool = False` — CLI control + - `link_resolution: LinkBatchResult | None` — results + +4. **Add progress display** + - File: `imas_codex/sn/progress.py` + - LINK stage: shows processed count, resolved/unresolved ratio + - No cost display (pure Python, no LLM) + +### Acceptance Criteria +- LINK phase runs after PERSIST_NODES in `sn build` +- Cross-references and dependencies become graph edges +- `--skip-link` flag bypasses the phase + +--- + +## Phase 3: Standalone `sn link` CLI Command + +### Design + +A standalone CLI command for iterative re-linking. This is the mechanism +for resolving forward references that couldn't be resolved during `sn build` +because the target names didn't exist yet. + +### Tasks + +1. **Add `sn link` CLI command** + - File: `imas_codex/cli/sn.py` + - Modes: + - `sn link` — re-link all names with unresolved references + - `sn link --all` — re-link all names (revalidate existing edges) + - `sn link --name electron_temperature` — re-link specific name + - Steps: + 1. Query graph for names with `unresolved_refs` (or all names if --all) + 2. Load current known names + 3. For each name: resolve previously-unresolved mentions + 4. Create new edges for newly-resolved refs + 5. Update `unresolved_refs` to remove resolved entries + 6. Update degree counters + - Report: "Resolved X/Y previously-unresolved references" + +2. **Add `sn status` link health section** + - File: `imas_codex/cli/sn.py` + - Add to existing `sn status` output: + ``` + Link Health: + Total edges: 1,234 (890 CROSS_REFERENCES + 344 DEPENDS_ON) + Unresolved: 45 names have unresolved references + Avg in-degree: 3.2 + Avg out-degree: 2.8 + Circular deps: 3 detected + ``` + +3. **Add link summary to `sn build` output** + - File: `imas_codex/cli/sn.py` + - After LINK phase completes, show summary: + ``` + Links: 87 resolved, 12 unresolved (run `sn link` to retry) + ``` + +### Acceptance Criteria +- `sn link` resolves previously-unresolved references +- Multiple `sn link` runs converge (fewer unresolved each time) +- `sn status` shows link health metrics +- `sn build` summary includes link statistics + +--- + +## Phase 4: Tests + +### Tasks + +1. **Test link resolution engine** + - File: `tests/sn/test_linker.py` + - Test `resolve_references()` with: + - All references resolved + - Some unresolved + - Circular references + - Empty mentions + - Mixed cross-references and dependencies + - Deterministic fixtures, no graph dependency + +2. **Test graph edge operations** + - File: `tests/sn/test_link_graph_ops.py` + - Test `write_cross_references()` idempotency + - Test `write_depends_on()` idempotency + - Test `update_degree_counters()` accuracy + - Test `get_unresolved_names()` query + - These require graph fixtures (follow existing test patterns) + +3. **Test `sn link` CLI command** + - File: `tests/sn/test_link_cli.py` + - Test standalone re-linking mode + - Test convergence across multiple runs + - Mock graph operations + +4. **Test LINK pipeline phase integration** + - File: `tests/sn/test_pipeline_link.py` + - Test LINK runs after PERSIST_NODES + - Test --skip-link flag + - Test pipeline with and without LINK enabled + +### Acceptance Criteria +- All tests pass +- Link resolution has 100% coverage for classification logic +- Graph operations tested for idempotency +- CLI tested for all modes + +--- + +## Files Modified / Created + +| File | Change | +|------|--------| +| `imas_codex/sn/linker.py` | NEW: Link resolution engine | +| `imas_codex/sn/graph_ops.py` | Add explicit relationship-writing functions | +| `imas_codex/sn/workers.py` | Add link_worker() | +| `imas_codex/sn/pipeline.py` | Add LINK WorkerSpec | +| `imas_codex/sn/state.py` | Add link_stats, link_phase, skip_link | +| `imas_codex/sn/progress.py` | Add link stage display | +| `imas_codex/cli/sn.py` | Add `sn link` command, --skip-link flag | +| `tests/sn/test_linker.py` | NEW: resolution engine tests | +| `tests/sn/test_link_graph_ops.py` | NEW: graph operation tests | +| `tests/sn/test_link_cli.py` | NEW: CLI tests | +| `tests/sn/test_pipeline_link.py` | NEW: pipeline integration tests | + +## Documentation Updates + +- AGENTS.md: Document `sn link` command and iterative re-linking workflow +- AGENTS.md: Document LINK phase position in pipeline +- AGENTS.md: Document CROSS_REFERENCES vs DEPENDS_ON relationship semantics diff --git a/pyproject.toml b/pyproject.toml index 5ac5a4d41..ddc2ebc31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,14 +111,11 @@ dev = [ "mypy>=1.15.0", "pre-commit>=4.2.0", "tqdm-stubs>=0.2.1", - # --- Interactive dev --- "ipython>=9.2.0", "ipykernel>=6.29.5", - # --- LLM & Discovery --- "litellm>=1.81.0", - # --- Graph build & schema --- "linkml>=1.9.3", "linkml-runtime>=1.9.5", @@ -126,7 +123,6 @@ dev = [ "networkx>=3.0,<4.0", "scikit-learn>=1.7.2", "hdbscan>=0.8.41", - # --- Wiki & document parsing --- "beautifulsoup4>=4.14.3", "Pillow>=11.0.0", @@ -135,7 +131,6 @@ dev = [ "openpyxl>=3.1.5", "nbformat>=5.10.4", "xlrd>=2.0.2", - # --- Auth & remote --- "keyring>=25.7.0", "secretstorage>=3.5.0", @@ -146,22 +141,20 @@ dev = [ # Only the cpu/gpu extras need it directly (for model downloads). # The [hf_xet] extra installs native Rust bindings that crash on some # environments (ITER SDCC). HF_HUB_DISABLE_XET=1 is set in __init__.py. - # --- Tree-sitter --- "tree-sitter>=0.25.2", "tree-sitter-language-pack>=0.13.0", "tree-sitter-gdl>=0.2.0", - # --- Testing --- "pytest>=8.4.2", "pytest-asyncio>=0.26.0", "pytest-cov>=6.1.1", "pytest-timeout>=2.1.0", "xlwt>=1.3.0", - # --- Serve (embedding + LLM proxy) --- "fastapi>=0.115.0", "uvicorn>=0.31.1", + "imas-standard-names", ] [project.urls] @@ -438,6 +431,7 @@ torch = [ { index = "pytorch-cpu", extra = "test" }, { index = "pytorch-gpu", extra = "gpu" }, ] +imas-standard-names = { path = "../imas-standard-names", editable = true } [tool.uv] # cpu, gpu, and test extras for torch are mutually exclusive with gpu diff --git a/scripts/build_models.py b/scripts/build_models.py index 8edb0c6b9..51fe87b96 100644 --- a/scripts/build_models.py +++ b/scripts/build_models.py @@ -238,13 +238,15 @@ def build_models( # Check if output already exists if output_file.exists() and not force: - # Check if any schema (facility or common) is newer than output + # Check if any schema (facility, common, standard_name) is newer facility_mtime = schema_file.stat().st_mtime common_mtime = ( common_schema_file.stat().st_mtime if common_schema_file.exists() else 0 ) + sn_schema = schemas_dir / "standard_name.yaml" + sn_mtime = sn_schema.stat().st_mtime if sn_schema.exists() else 0 output_mtime = output_file.stat().st_mtime - if max(facility_mtime, common_mtime) <= output_mtime: + if max(facility_mtime, common_mtime, sn_mtime) <= output_mtime: logger.info(f"Models up to date at {output_file}") click.echo(f"Models up to date: {output_file}") else: @@ -307,15 +309,17 @@ def build_models( if imas_schema_file.exists(): needs_regen = not imas_output_file.exists() or force if imas_output_file.exists() and not force: - # Check both imas_dd.yaml and common.yaml timestamps + # Check imas_dd.yaml, common.yaml, and standard_name.yaml timestamps imas_mtime = imas_schema_file.stat().st_mtime common_mtime = ( common_schema_file.stat().st_mtime if common_schema_file.exists() else 0 ) + sn_schema = schemas_dir / "standard_name.yaml" + sn_mtime = sn_schema.stat().st_mtime if sn_schema.exists() else 0 output_mtime = imas_output_file.stat().st_mtime - if max(imas_mtime, common_mtime) > output_mtime: + if max(imas_mtime, common_mtime, sn_mtime) > output_mtime: needs_regen = True logger.info( "IMAS DD or common schema newer than models, regenerating..." diff --git a/scripts/gen_schema_context.py b/scripts/gen_schema_context.py index 3ac979a60..2523992e4 100644 --- a/scripts/gen_schema_context.py +++ b/scripts/gen_schema_context.py @@ -46,6 +46,7 @@ def generate_schema_context( schemas_dir / "facility.yaml", schemas_dir / "common.yaml", schemas_dir / "imas_dd.yaml", + schemas_dir / "standard_name.yaml", ] task_groups_file = schemas_dir / "task_groups.yaml" diff --git a/tests/core/test_paths.py b/tests/core/test_paths.py index e31480d49..f447bb2da 100644 --- a/tests/core/test_paths.py +++ b/tests/core/test_paths.py @@ -2,7 +2,11 @@ import pytest -from imas_codex.core.paths import strip_path_annotations +from imas_codex.core.paths import ( + _looks_like_path, + normalize_imas_path, + strip_path_annotations, +) class TestStripPathAnnotations: @@ -35,3 +39,278 @@ class TestStripPathAnnotations: ) def test_strip(self, input_path: str, expected: str) -> None: assert strip_path_annotations(input_path) == expected + + +# --------------------------------------------------------------------------- +# _looks_like_path +# --------------------------------------------------------------------------- + + +class TestLooksLikePath: + """Guard function that distinguishes IMAS paths from natural language.""" + + @pytest.mark.parametrize( + "text", + [ + # Dot-separated IMAS paths + "equilibrium.time_slice.profiles_1d.psi", + "core_profiles.profiles_1d.electrons.temperature", + "magnetics.ip.data", + # Slash-separated IMAS paths + "equilibrium/time_slice/profiles_1d/psi", + "magnetics/flux_loop/flux/data", + # Mixed dot/slash + "equilibrium.time_slice/profiles_1d", + ], + ) + def test_path_detected(self, text: str) -> None: + assert _looks_like_path(text) is True + + @pytest.mark.parametrize( + "text", + [ + # Natural language with spaces + "electron temperature", + "plasma current measurement", + # Natural language with periods + "electron temperature e.g. in eV", + "Find B0.", + "temperature i.e. Te", + "plasma current. Also check safety factor.", + # Single words (no separator) + "equilibrium", + "magnetics", + "ip", + "", + # Units with special chars + "m^-1.s^-2", + "eV.s", + # Numeric/version strings + "3.39.0", + ], + ) + def test_non_path_rejected(self, text: str) -> None: + assert _looks_like_path(text) is False + + +# --------------------------------------------------------------------------- +# normalize_imas_path — dot-notation conversion +# --------------------------------------------------------------------------- + + +class TestNormalizeImasPathDotNotation: + """Dot→slash conversion for IMAS paths, with natural-language safety.""" + + # --- Dot-notation paths: dots MUST become slashes --- + + @pytest.mark.parametrize( + "input_path,expected", + [ + # Pure dot notation + ( + "equilibrium.time_slice.profiles_1d.psi", + "equilibrium/time_slice/profiles_1d/psi", + ), + ( + "core_profiles.profiles_1d.electrons.temperature", + "core_profiles/profiles_1d/electrons/temperature", + ), + ("magnetics.ip.data", "magnetics/ip/data"), + # Mixed dot/slash + ( + "equilibrium.time_slice/profiles_1d", + "equilibrium/time_slice/profiles_1d", + ), + # Two segments + ("magnetics.ip", "magnetics/ip"), + ], + ) + def test_dots_converted_to_slashes(self, input_path: str, expected: str) -> None: + assert normalize_imas_path(input_path) == expected + + # --- Slash-notation paths: pass through unchanged --- + + @pytest.mark.parametrize( + "input_path,expected", + [ + ( + "equilibrium/time_slice/profiles_1d/psi", + "equilibrium/time_slice/profiles_1d/psi", + ), + ("magnetics/flux_loop/flux/data", "magnetics/flux_loop/flux/data"), + ], + ) + def test_slash_paths_unchanged(self, input_path: str, expected: str) -> None: + assert normalize_imas_path(input_path) == expected + + # --- Natural language: dots MUST NOT become slashes --- + + @pytest.mark.parametrize( + "query", + [ + "electron temperature e.g. in eV", + "Find B0.", + "temperature i.e. Te", + "plasma current. Also check safety factor.", + "What is the toroidal field B0?", + "Find plasma current measurement", + "electron density profile for ITER scenario 2.", + ], + ) + def test_natural_language_preserved(self, query: str) -> None: + """Natural language with periods must not be mangled.""" + result = normalize_imas_path(query) + # Stripping is allowed, but dots must remain dots + assert "." in query.strip() if "." in query else True + # No slash should appear where there wasn't one + original_slashes = query.strip().count("/") + result_slashes = result.count("/") + assert result_slashes == original_slashes, ( + f"Dot→slash leaked into natural language: {query!r} → {result!r}" + ) + + +# --------------------------------------------------------------------------- +# normalize_imas_path — annotation stripping +# --------------------------------------------------------------------------- + + +class TestNormalizeImasPathAnnotations: + """Index/array annotation stripping combined with dot-notation.""" + + @pytest.mark.parametrize( + "input_path,expected", + [ + # Parenthesized annotations + ("flux_loop(i1)/flux/data(:)", "flux_loop/flux/data"), + ( + "time_slice(itime)/profiles_1d(i1)/psi", + "time_slice/profiles_1d/psi", + ), + # Bracket annotations + ("time_slice[1]/profiles_1d[:]/psi", "time_slice/profiles_1d/psi"), + ("channel[0]/position/r", "channel/position/r"), + # Dot-notation WITH annotations + ( + "equilibrium.time_slice(itime).profiles_1d.psi", + "equilibrium/time_slice/profiles_1d/psi", + ), + # Mixed dots + brackets + ( + "magnetics.flux_loop[0].flux.data", + "magnetics/flux_loop/flux/data", + ), + ], + ) + def test_annotations_stripped(self, input_path: str, expected: str) -> None: + assert normalize_imas_path(input_path) == expected + + +# --------------------------------------------------------------------------- +# normalize_imas_path — whitespace and edge cases +# --------------------------------------------------------------------------- + + +class TestNormalizeImasPathEdgeCases: + """Whitespace handling, empty strings, single-word inputs.""" + + @pytest.mark.parametrize( + "input_path,expected", + [ + # Leading/trailing whitespace stripped + (" equilibrium/time_slice ", "equilibrium/time_slice"), + (" equilibrium.time_slice ", "equilibrium/time_slice"), + # Leading/trailing slashes stripped + ("/equilibrium/time_slice/", "equilibrium/time_slice"), + # Single word (no separator) — passthrough + ("equilibrium", "equilibrium"), + ("magnetics", "magnetics"), + # Empty / whitespace-only + ("", ""), + (" ", ""), + ], + ) + def test_edge_cases(self, input_path: str, expected: str) -> None: + assert normalize_imas_path(input_path) == expected + + +# --------------------------------------------------------------------------- +# _normalize_paths — multi-path splitting + per-path normalization +# --------------------------------------------------------------------------- + + +class TestNormalizePaths: + """The _normalize_paths helper splits then normalizes each path.""" + + @pytest.fixture(autouse=True) + def _import(self) -> None: + from imas_codex.tools.graph_search import _normalize_paths + + self._normalize = _normalize_paths + + # --- Space-separated dot-notation paths --- + + def test_space_separated_dot_paths(self) -> None: + """Multiple dot-notation paths separated by spaces must each convert.""" + result = self._normalize( + "equilibrium.time_slice.profiles_1d.psi " + "core_profiles.profiles_1d.electrons.temperature" + ) + assert result == [ + "equilibrium/time_slice/profiles_1d/psi", + "core_profiles/profiles_1d/electrons/temperature", + ] + + def test_comma_separated_dot_paths(self) -> None: + """Comma-separated dot-notation paths must each convert.""" + result = self._normalize( + "equilibrium.time_slice.profiles_1d.psi,magnetics.ip.data" + ) + assert result == [ + "equilibrium/time_slice/profiles_1d/psi", + "magnetics/ip/data", + ] + + def test_mixed_notation_multi_path(self) -> None: + """Mix of dot, slash, and annotated paths in one string.""" + result = self._normalize( + "equilibrium.time_slice.profiles_1d.psi " + "magnetics/flux_loop/flux/data " + "core_profiles.profiles_1d(i1).electrons.temperature" + ) + assert result == [ + "equilibrium/time_slice/profiles_1d/psi", + "magnetics/flux_loop/flux/data", + "core_profiles/profiles_1d/electrons/temperature", + ] + + def test_list_input_dot_paths(self) -> None: + """List[str] input with dot-notation paths.""" + result = self._normalize( + [ + "equilibrium.time_slice.profiles_1d.psi", + "magnetics.ip.data", + ] + ) + assert result == [ + "equilibrium/time_slice/profiles_1d/psi", + "magnetics/ip/data", + ] + + def test_json_array_dot_paths(self) -> None: + """JSON array string with dot-notation paths.""" + result = self._normalize( + '["equilibrium.time_slice.profiles_1d.psi", "magnetics.ip.data"]' + ) + assert result == [ + "equilibrium/time_slice/profiles_1d/psi", + "magnetics/ip/data", + ] + + def test_single_dot_path(self) -> None: + result = self._normalize("equilibrium.time_slice") + assert result == ["equilibrium/time_slice"] + + def test_single_slash_path(self) -> None: + result = self._normalize("equilibrium/time_slice") + assert result == ["equilibrium/time_slice"] diff --git a/tests/graph_mcp/test_graph_search.py b/tests/graph_mcp/test_graph_search.py index 601f0c957..e6fe5e526 100644 --- a/tests/graph_mcp/test_graph_search.py +++ b/tests/graph_mcp/test_graph_search.py @@ -710,7 +710,7 @@ async def test_export_exact_domain(self, graph_client): """Exact domain name should return paths.""" tool = self._make_tool(graph_client) # 'equilibrium' is stored on equilibrium paths in fixtures - result = await tool.export_imas_domain(domain="equilibrium") + result = await tool.export_dd_domain(domain="equilibrium") assert result["total_paths"] > 0 assert "equilibrium" in result["resolved_domains"] @@ -718,7 +718,7 @@ async def test_export_exact_domain(self, graph_client): async def test_export_ids_name(self, graph_client): """IDS name should resolve and export domain paths.""" tool = self._make_tool(graph_client) - result = await tool.export_imas_domain(domain="core_profiles") + result = await tool.export_dd_domain(domain="core_profiles") assert result["total_paths"] > 0 assert result["resolution"] == "ids_name:core_profiles" assert "transport" in result["resolved_domains"] @@ -727,7 +727,7 @@ async def test_export_ids_name(self, graph_client): async def test_export_no_match(self, graph_client): """No-match domain should return error.""" tool = self._make_tool(graph_client) - result = await tool.export_imas_domain(domain="nonexistent_xyz") + result = await tool.export_dd_domain(domain="nonexistent_xyz") assert result["total_paths"] == 0 assert "error" in result diff --git a/tests/llm/test_graceful_degradation.py b/tests/llm/test_graceful_degradation.py index a557dc9f1..afd6c2c19 100644 --- a/tests/llm/test_graceful_degradation.py +++ b/tests/llm/test_graceful_degradation.py @@ -253,8 +253,8 @@ def test_semantic_search_triggers_full_warmup(self, mock_graph_warmup): "get_dd_identifiers", "get_dd_versions", "get_dd_version_context", - "export_imas_ids", - "export_imas_domain", + "export_dd_ids", + "export_dd_domain", } diff --git a/tests/llm/test_mcp_bug_regressions.py b/tests/llm/test_mcp_bug_regressions.py index e53b209eb..870577d0c 100644 --- a/tests/llm/test_mcp_bug_regressions.py +++ b/tests/llm/test_mcp_bug_regressions.py @@ -15,7 +15,7 @@ import pytest # --------------------------------------------------------------------------- -# Bug 1 & 2: export_imas_ids / export_imas_domain must NOT expose +# Bug 1 & 2: export_dd_ids / export_dd_domain must NOT expose # ``include_errors`` — the underlying GraphStructureTool methods don't # accept that parameter, so passing it would raise TypeError. # --------------------------------------------------------------------------- @@ -24,29 +24,29 @@ class TestExportHandlersNoIncludeErrors: """Bug 1 & 2: Server handlers must not pass include_errors to graph tools.""" - def test_export_imas_ids_tool_has_no_include_errors_param(self): - """GraphStructureTool.export_imas_ids must not accept include_errors.""" + def test_export_dd_ids_tool_has_no_include_errors_param(self): + """GraphStructureTool.export_dd_ids must not accept include_errors.""" from imas_codex.tools.graph_search import GraphStructureTool - sig = inspect.signature(GraphStructureTool.export_imas_ids) + sig = inspect.signature(GraphStructureTool.export_dd_ids) assert "include_errors" not in sig.parameters, ( - "GraphStructureTool.export_imas_ids gained an unexpected " + "GraphStructureTool.export_dd_ids gained an unexpected " "'include_errors' parameter — the server handler must not " "pass this kwarg" ) - def test_export_imas_domain_tool_has_no_include_errors_param(self): - """GraphStructureTool.export_imas_domain must not accept include_errors.""" + def test_export_dd_domain_tool_has_no_include_errors_param(self): + """GraphStructureTool.export_dd_domain must not accept include_errors.""" from imas_codex.tools.graph_search import GraphStructureTool - sig = inspect.signature(GraphStructureTool.export_imas_domain) + sig = inspect.signature(GraphStructureTool.export_dd_domain) assert "include_errors" not in sig.parameters, ( - "GraphStructureTool.export_imas_domain gained an unexpected " + "GraphStructureTool.export_dd_domain gained an unexpected " "'include_errors' parameter — the server handler must not " "pass this kwarg" ) - def test_export_imas_ids_server_handler_no_include_errors(self): + def test_export_dd_ids_server_handler_no_include_errors(self): """The DD-only server handler for export_imas_ids must omit include_errors.""" from imas_codex.llm.server import AgentsServer @@ -61,7 +61,7 @@ def test_export_imas_ids_server_handler_no_include_errors(self): ) break - def test_export_imas_domain_server_handler_no_include_errors(self): + def test_export_dd_domain_server_handler_no_include_errors(self): """The DD-only server handler for export_imas_domain must omit include_errors.""" from imas_codex.llm.server import AgentsServer diff --git a/tests/sn/__init__.py b/tests/sn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/sn/test_benchmark.py b/tests/sn/test_benchmark.py new file mode 100644 index 000000000..ce7b6d40f --- /dev/null +++ b/tests/sn/test_benchmark.py @@ -0,0 +1,658 @@ +"""Tests for the SN benchmarking system.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from imas_standard_names.grammar import compose_standard_name, parse_standard_name + +# ----------------------------------------------------------------------- +# Reference dataset tests +# ----------------------------------------------------------------------- + + +class TestReferenceDataset: + """Verify the reference dataset is valid and self-consistent.""" + + def test_reference_not_empty(self): + from imas_codex.sn.benchmark_reference import REFERENCE_NAMES + + assert len(REFERENCE_NAMES) >= 20, "Reference set should have >= 20 entries" + + def test_all_names_round_trip(self): + """Every reference name must survive parse→compose round-trip.""" + from imas_codex.sn.benchmark_reference import REFERENCE_NAMES + + failures = [] + for path, entry in REFERENCE_NAMES.items(): + name = entry["name"] + try: + parsed = parse_standard_name(name) + rt = compose_standard_name(parsed) + if rt != name: + failures.append(f"{path}: {name!r} → {rt!r}") + except Exception as e: + failures.append(f"{path}: {name!r} raised {e!s:.80s}") + + assert not failures, "Round-trip failures:\n" + "\n".join(failures) + + def test_all_entries_have_required_keys(self): + from imas_codex.sn.benchmark_reference import REFERENCE_NAMES + + for path, entry in REFERENCE_NAMES.items(): + assert "name" in entry, f"Missing 'name' in {path}" + assert "fields" in entry, f"Missing 'fields' in {path}" + assert isinstance(entry["name"], str), f"name must be str in {path}" + assert isinstance(entry["fields"], dict), f"fields must be dict in {path}" + + def test_all_fields_have_physical_or_geometric_base(self): + from imas_codex.sn.benchmark_reference import REFERENCE_NAMES + + for path, entry in REFERENCE_NAMES.items(): + fields = entry["fields"] + has_physical = "physical_base" in fields + has_geometric = "geometric_base" in fields + assert has_physical or has_geometric, ( + f"{path}: must have physical_base or geometric_base, got {fields}" + ) + + def test_paths_look_like_dd_paths(self): + from imas_codex.sn.benchmark_reference import REFERENCE_NAMES + + for path in REFERENCE_NAMES: + assert "/" in path, f"Path should contain '/': {path}" + assert not path.startswith("/"), f"Path should not start with '/': {path}" + + +# ----------------------------------------------------------------------- +# Dataclass tests +# ----------------------------------------------------------------------- + + +class TestDataclasses: + """Verify dataclass instantiation and basic behavior.""" + + def test_benchmark_config_defaults(self): + from imas_codex.sn.benchmark import BenchmarkConfig + + cfg = BenchmarkConfig(models=["model-a", "model-b"]) + assert cfg.source == "dd" + assert cfg.max_candidates == 50 + assert cfg.runs_per_model == 1 + assert cfg.temperature == 0.0 + assert cfg.ids_filter is None + + def test_benchmark_config_custom(self): + from imas_codex.sn.benchmark import BenchmarkConfig + + cfg = BenchmarkConfig( + models=["m1"], + source="signals", + ids_filter="equilibrium", + domain_filter="magnetics", + facility="tcv", + max_candidates=100, + runs_per_model=3, + temperature=0.5, + ) + assert cfg.facility == "tcv" + assert cfg.runs_per_model == 3 + + def test_model_result_defaults(self): + from imas_codex.sn.benchmark import ModelResult + + r = ModelResult(model="test-model") + assert r.model == "test-model" + assert r.candidates == [] + assert r.grammar_valid_count == 0 + assert r.total_cost == 0.0 + assert r.names_per_minute == 0.0 + + def test_benchmark_report_instantiation(self): + from imas_codex.sn.benchmark import ( + BenchmarkConfig, + BenchmarkReport, + ModelResult, + ) + + cfg = BenchmarkConfig(models=["m"]) + mr = ModelResult(model="m", grammar_valid_count=5) + report = BenchmarkReport( + config=cfg, + results=[mr], + reference_names=["a", "b"], + extraction_count=10, + timestamp="2025-01-01T00:00:00", + ) + assert report.extraction_count == 10 + assert len(report.results) == 1 + + +# ----------------------------------------------------------------------- +# Grammar context builder tests +# ----------------------------------------------------------------------- + + +class TestGrammarContext: + """Verify grammar context builder provides all template variables.""" + + def test_build_grammar_context_keys(self): + from imas_codex.sn.benchmark import build_grammar_context + + ctx = build_grammar_context() + expected_keys = { + "subjects", + "positions", + "components", + "coordinates", + "processes", + "transformations", + "geometric_bases", + "objects", + "binary_operators", + } + assert set(ctx.keys()) == expected_keys + + def test_all_values_non_empty(self): + from imas_codex.sn.benchmark import build_grammar_context + + ctx = build_grammar_context() + for key, values in ctx.items(): + assert len(values) > 0, f"{key} should have at least one value" + assert all(isinstance(v, str) for v in values), ( + f"{key} values must be strings" + ) + + +# ----------------------------------------------------------------------- +# Validation tests +# ----------------------------------------------------------------------- + + +class TestValidation: + """Test the candidate validation logic.""" + + def test_valid_candidate(self): + from imas_codex.sn.benchmark import validate_candidate + + candidate = { + "standard_name": "electron_temperature", + "fields": {"physical_base": "temperature", "subject": "electron"}, + } + g_valid, f_consistent = validate_candidate(candidate) + assert g_valid is True + assert f_consistent is True + + def test_invalid_grammar(self): + from imas_codex.sn.benchmark import validate_candidate + + candidate = { + "standard_name": "this_is_not_valid_!!!", + "fields": {"physical_base": "nonsense"}, + } + g_valid, f_consistent = validate_candidate(candidate) + assert g_valid is False + assert f_consistent is False + + def test_valid_grammar_inconsistent_fields(self): + from imas_codex.sn.benchmark import validate_candidate + + candidate = { + "standard_name": "electron_temperature", + "fields": {"physical_base": "density", "subject": "ion"}, + } + g_valid, f_consistent = validate_candidate(candidate) + assert g_valid is True + assert f_consistent is False + + def test_empty_candidate(self): + from imas_codex.sn.benchmark import validate_candidate + + g_valid, f_consistent = validate_candidate({}) + assert g_valid is False + assert f_consistent is False + + +# ----------------------------------------------------------------------- +# Reference comparison tests +# ----------------------------------------------------------------------- + + +class TestReferenceComparison: + """Test reference set comparison logic.""" + + def test_full_overlap(self): + from imas_codex.sn.benchmark import compare_to_reference + + reference = { + "path/a": {"name": "electron_temperature", "fields": {}}, + "path/b": {"name": "safety_factor", "fields": {}}, + } + candidates = [ + {"source_id": "path/a", "standard_name": "electron_temperature"}, + {"source_id": "path/b", "standard_name": "safety_factor"}, + ] + overlap, total, precision, recall = compare_to_reference(candidates, reference) + assert overlap == 2 + assert total == 2 + assert recall == 1.0 + + def test_no_overlap(self): + from imas_codex.sn.benchmark import compare_to_reference + + reference = { + "path/a": {"name": "electron_temperature", "fields": {}}, + } + candidates = [ + {"source_id": "path/a", "standard_name": "ion_temperature"}, + ] + overlap, total, precision, recall = compare_to_reference(candidates, reference) + assert overlap == 0 + assert total == 1 + assert recall == 0.0 + + def test_partial_overlap(self): + from imas_codex.sn.benchmark import compare_to_reference + + reference = { + "path/a": {"name": "electron_temperature", "fields": {}}, + "path/b": {"name": "safety_factor", "fields": {}}, + } + candidates = [ + {"source_id": "path/a", "standard_name": "electron_temperature"}, + {"source_id": "path/b", "standard_name": "beta"}, + {"source_id": "path/c", "standard_name": "elongation"}, + ] + overlap, total, precision, recall = compare_to_reference(candidates, reference) + assert overlap == 1 + assert total == 2 + assert recall == 0.5 + + def test_empty_candidates(self): + from imas_codex.sn.benchmark import compare_to_reference + + overlap, total, precision, recall = compare_to_reference( + [], {"path/a": {"name": "x", "fields": {}}} + ) + assert overlap == 0 + assert precision == 0.0 + + +# ----------------------------------------------------------------------- +# JSON serialization tests +# ----------------------------------------------------------------------- + + +class TestJsonSerialization: + """Test JSON round-trip for BenchmarkReport.""" + + def test_json_round_trip(self): + from imas_codex.sn.benchmark import ( + BenchmarkConfig, + BenchmarkReport, + ModelResult, + ) + + cfg = BenchmarkConfig( + models=["model-a", "model-b"], + source="dd", + ids_filter="equilibrium", + max_candidates=25, + ) + r1 = ModelResult( + model="model-a", + candidates=[{"source_id": "x", "standard_name": "electron_temperature"}], + grammar_valid_count=1, + grammar_invalid_count=0, + total_cost=0.05, + total_tokens=500, + elapsed_seconds=12.5, + names_per_minute=4.8, + cost_per_name=0.05, + ) + r2 = ModelResult(model="model-b") + report = BenchmarkReport( + config=cfg, + results=[r1, r2], + reference_names=["path/a", "path/b"], + extraction_count=10, + timestamp="2025-01-15T12:00:00+00:00", + ) + + json_str = report.to_json() + parsed = json.loads(json_str) + + # Verify structure + assert parsed["config"]["models"] == ["model-a", "model-b"] + assert len(parsed["results"]) == 2 + assert parsed["results"][0]["model"] == "model-a" + assert parsed["results"][0]["total_cost"] == 0.05 + assert parsed["extraction_count"] == 10 + + def test_from_json(self): + from imas_codex.sn.benchmark import ( + BenchmarkConfig, + BenchmarkReport, + ModelResult, + ) + + cfg = BenchmarkConfig(models=["m1"]) + r = ModelResult(model="m1", grammar_valid_count=3) + original = BenchmarkReport( + config=cfg, + results=[r], + reference_names=["a"], + extraction_count=5, + timestamp="2025-01-01", + ) + + json_str = original.to_json() + restored = BenchmarkReport.from_json(json_str) + + assert restored.config.models == ["m1"] + assert restored.results[0].model == "m1" + assert restored.results[0].grammar_valid_count == 3 + assert restored.extraction_count == 5 + assert restored.timestamp == "2025-01-01" + + +# ----------------------------------------------------------------------- +# Rich table rendering tests +# ----------------------------------------------------------------------- + + +class TestRichTable: + """Verify the Rich comparison table renders without error.""" + + def test_render_empty_report(self): + from imas_codex.sn.benchmark import ( + BenchmarkConfig, + BenchmarkReport, + render_comparison_table, + ) + + report = BenchmarkReport( + config=BenchmarkConfig(models=[]), + results=[], + reference_names=[], + timestamp="2025-01-01", + ) + # Should not raise + render_comparison_table(report) + + def test_render_with_results(self): + from imas_codex.sn.benchmark import ( + BenchmarkConfig, + BenchmarkReport, + ModelResult, + render_comparison_table, + ) + + r = ModelResult( + model="test-model", + candidates=[{"source_id": "p", "standard_name": "electron_temperature"}], + grammar_valid_count=1, + grammar_invalid_count=0, + fields_consistent_count=1, + total_cost=0.01, + total_tokens=100, + elapsed_seconds=5.0, + names_per_minute=12.0, + cost_per_name=0.01, + reference_overlap=1, + reference_total=10, + ) + report = BenchmarkReport( + config=BenchmarkConfig(models=["test-model"]), + results=[r], + reference_names=["a"], + extraction_count=5, + timestamp="2025-01-01", + ) + # Should not raise + render_comparison_table(report) + + def test_render_with_zero_candidates(self): + """Model that produced zero candidates should show '—' not crash.""" + from imas_codex.sn.benchmark import ( + BenchmarkConfig, + BenchmarkReport, + ModelResult, + render_comparison_table, + ) + + r = ModelResult(model="empty-model") + report = BenchmarkReport( + config=BenchmarkConfig(models=["empty-model"]), + results=[r], + reference_names=[], + timestamp="2025-01-01", + ) + render_comparison_table(report) + + +# ----------------------------------------------------------------------- +# Benchmark runner tests (mocked LLM) +# ----------------------------------------------------------------------- + + +class TestBenchmarkRunner: + """Test the async benchmark runner with mocked LLM calls.""" + + @pytest.mark.asyncio + async def test_run_benchmark_mocked(self): + """Run benchmark with mocked extraction and LLM calls.""" + from imas_codex.sn.benchmark import BenchmarkConfig, run_benchmark + from imas_codex.sn.models import SNCandidate, SNComposeBatch + + config = BenchmarkConfig( + models=["mock-model-a"], + max_candidates=5, + ) + + # Fake extraction batches + fake_batches = [ + { + "group_key": "equilibrium", + "items": [ + { + "path": "equilibrium/time_slice/profiles_1d/safety_factor", + "description": "Safety factor", + "units": None, + "data_type": "FLT_1D", + "cluster_label": "safety_factor", + }, + { + "path": "core_profiles/profiles_1d/electrons/temperature", + "description": "Electron temperature", + "units": "eV", + "data_type": "FLT_1D", + "cluster_label": "electron_temperature", + }, + ], + "existing_names": [], + } + ] + + # Mock LLM response + mock_response = SNComposeBatch( + candidates=[ + SNCandidate( + source_id="equilibrium/time_slice/profiles_1d/safety_factor", + standard_name="safety_factor", + fields={"physical_base": "safety_factor"}, + confidence=0.95, + reason="Safety factor profile", + ), + SNCandidate( + source_id="core_profiles/profiles_1d/electrons/temperature", + standard_name="electron_temperature", + fields={"physical_base": "temperature", "subject": "electron"}, + confidence=0.98, + reason="Electron temperature", + ), + ], + skipped=[], + ) + + with ( + patch( + "imas_codex.discovery.base.llm.acall_llm_structured", + new_callable=AsyncMock, + return_value=(mock_response, 0.01, 200), + ), + patch( + "imas_codex.llm.prompt_loader.render_prompt", + return_value="mocked prompt text", + ), + ): + report = await run_benchmark(config, extraction_batches=fake_batches) + + assert len(report.results) == 1 + r = report.results[0] + assert r.model == "mock-model-a" + assert len(r.candidates) == 2 + assert r.grammar_valid_count == 2 + assert r.grammar_invalid_count == 0 + assert r.total_cost == 0.01 + assert r.total_tokens == 200 + assert r.elapsed_seconds >= 0 + + @pytest.mark.asyncio + async def test_run_benchmark_multiple_models(self): + """Run with multiple models and verify separate results.""" + from imas_codex.sn.benchmark import BenchmarkConfig, run_benchmark + from imas_codex.sn.models import SNCandidate, SNComposeBatch + + config = BenchmarkConfig(models=["model-a", "model-b"], max_candidates=2) + + fake_batches = [ + { + "group_key": "test", + "items": [ + { + "path": "equilibrium/time_slice/profiles_1d/elongation", + "description": "Elongation", + "units": None, + "data_type": "FLT_1D", + "cluster_label": None, + }, + ], + "existing_names": [], + } + ] + + mock_response = SNComposeBatch( + candidates=[ + SNCandidate( + source_id="equilibrium/time_slice/profiles_1d/elongation", + standard_name="elongation", + fields={"physical_base": "elongation"}, + confidence=0.9, + reason="Plasma elongation", + ), + ], + skipped=[], + ) + + with ( + patch( + "imas_codex.discovery.base.llm.acall_llm_structured", + new_callable=AsyncMock, + return_value=(mock_response, 0.005, 100), + ), + patch( + "imas_codex.llm.prompt_loader.render_prompt", + return_value="prompt", + ), + ): + report = await run_benchmark(config, extraction_batches=fake_batches) + + assert len(report.results) == 2 + assert report.results[0].model == "model-a" + assert report.results[1].model == "model-b" + # Both should have the same structure since same mock + for r in report.results: + assert len(r.candidates) == 1 + assert r.grammar_valid_count == 1 + + @pytest.mark.asyncio + async def test_run_benchmark_llm_failure(self): + """LLM call failure should be recorded, not crash.""" + from imas_codex.sn.benchmark import BenchmarkConfig, run_benchmark + + config = BenchmarkConfig(models=["fail-model"], max_candidates=2) + + fake_batches = [ + { + "group_key": "test", + "items": [ + { + "path": "test/path", + "description": "Test", + "units": None, + "data_type": "FLT_0D", + "cluster_label": None, + } + ], + "existing_names": [], + } + ] + + with ( + patch( + "imas_codex.discovery.base.llm.acall_llm_structured", + new_callable=AsyncMock, + side_effect=RuntimeError("LLM unavailable"), + ), + patch( + "imas_codex.llm.prompt_loader.render_prompt", + return_value="prompt", + ), + ): + report = await run_benchmark(config, extraction_batches=fake_batches) + + assert len(report.results) == 1 + r = report.results[0] + assert r.batch_errors == 1 + assert len(r.candidates) == 0 + + +# ----------------------------------------------------------------------- +# CLI command tests +# ----------------------------------------------------------------------- + + +class TestCLICommand: + """Verify the CLI benchmark command is registered and callable.""" + + def test_command_exists(self): + from imas_codex.cli.sn import sn + + cmd = sn.get_command(None, "benchmark") + assert cmd is not None, "benchmark command should be registered" + + def test_command_help(self): + from click.testing import CliRunner + + from imas_codex.cli.sn import sn + + runner = CliRunner() + result = runner.invoke(sn, ["benchmark", "--help"]) + assert result.exit_code == 0 + assert "--models" in result.output + assert "--max-candidates" in result.output + assert "--output" in result.output + assert "--temperature" in result.output + + def test_command_requires_models(self): + from click.testing import CliRunner + + from imas_codex.cli.sn import sn + + runner = CliRunner() + result = runner.invoke(sn, ["benchmark"]) + assert result.exit_code != 0 + assert "Missing" in result.output or "required" in result.output.lower() diff --git a/tests/sn/test_publish.py b/tests/sn/test_publish.py new file mode 100644 index 000000000..73be92b30 --- /dev/null +++ b/tests/sn/test_publish.py @@ -0,0 +1,484 @@ +"""Tests for the standard name publish module. + +Tests YAML generation, batching, duplicate checking, model validation, +and graph record conversion — all pure-function tests that don't require +a live Neo4j connection. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from imas_codex.sn.models import SNProvenance, SNPublishBatch, SNPublishEntry +from imas_codex.sn.publish import ( + batch_by_group, + check_catalog_duplicates, + confidence_tier, + generate_catalog_files, + generate_yaml_entry, + graph_records_to_entries, + make_publish_batches, +) + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture() +def sample_provenance() -> SNProvenance: + return SNProvenance( + source="dd", + source_id="equilibrium/time_slice/profiles_1d/electrons/temperature", + ids_name="equilibrium", + confidence=0.95, + generated_by="imas-codex", + ) + + +@pytest.fixture() +def sample_entry(sample_provenance: SNProvenance) -> SNPublishEntry: + return SNPublishEntry( + name="electron_temperature", + kind="physical", + unit="eV", + tags=["equilibrium", "core_profiles"], + status="candidate", + description="Electron temperature profile", + provenance=sample_provenance, + ) + + +@pytest.fixture() +def sample_entries() -> list[SNPublishEntry]: + """A diverse set of entries for batching / grouping tests.""" + return [ + SNPublishEntry( + name="electron_temperature", + kind="physical", + unit="eV", + tags=["equilibrium"], + description="Electron temperature", + provenance=SNProvenance( + source="dd", + source_id="equilibrium/time_slice/profiles_1d/electrons/temperature", + ids_name="equilibrium", + confidence=0.95, + ), + ), + SNPublishEntry( + name="electron_density", + kind="physical", + unit="m^-3", + tags=["core_profiles"], + description="Electron density", + provenance=SNProvenance( + source="dd", + source_id="core_profiles/profiles_1d/electrons/density", + ids_name="core_profiles", + confidence=0.88, + ), + ), + SNPublishEntry( + name="plasma_current", + kind="physical", + unit="A", + tags=["equilibrium"], + description="Plasma current", + provenance=SNProvenance( + source="dd", + source_id="equilibrium/time_slice/global_quantities/ip", + ids_name="equilibrium", + confidence=0.45, + ), + ), + SNPublishEntry( + name="major_radius", + kind="geometric", + unit="m", + tags=["equilibrium"], + description="Major radius", + provenance=SNProvenance( + source="signals", + source_id="tcv:rmajor", + ids_name=None, + confidence=0.72, + ), + ), + ] + + +# ============================================================================= +# Model validation tests +# ============================================================================= + + +class TestSNProvenance: + def test_valid_provenance(self) -> None: + p = SNProvenance( + source="dd", + source_id="equilibrium/time_slice/profiles_1d/psi", + confidence=0.9, + ) + assert p.source == "dd" + assert p.generated_by == "imas-codex" + + def test_confidence_bounds(self) -> None: + with pytest.raises(ValueError): + SNProvenance(source="dd", source_id="x", confidence=1.5) + with pytest.raises(ValueError): + SNProvenance(source="dd", source_id="x", confidence=-0.1) + + def test_optional_ids_name(self) -> None: + p = SNProvenance(source="signals", source_id="sig:x", confidence=0.5) + assert p.ids_name is None + + +class TestSNPublishEntry: + def test_defaults(self, sample_provenance: SNProvenance) -> None: + entry = SNPublishEntry( + name="test_name", + provenance=sample_provenance, + ) + assert entry.kind == "physical" + assert entry.status == "candidate" + assert entry.tags == [] + assert entry.unit is None + + def test_all_fields(self, sample_entry: SNPublishEntry) -> None: + assert sample_entry.name == "electron_temperature" + assert sample_entry.kind == "physical" + assert sample_entry.unit == "eV" + assert "equilibrium" in sample_entry.tags + assert sample_entry.provenance.confidence == 0.95 + + +class TestSNPublishBatch: + def test_batch_creation(self, sample_entry: SNPublishEntry) -> None: + batch = SNPublishBatch( + group_key="equilibrium", + entries=[sample_entry], + confidence_tier="high", + ) + assert batch.group_key == "equilibrium" + assert len(batch.entries) == 1 + assert batch.confidence_tier == "high" + + +# ============================================================================= +# Confidence tier tests +# ============================================================================= + + +class TestConfidenceTier: + def test_high(self) -> None: + assert confidence_tier(0.8) == "high" + assert confidence_tier(0.95) == "high" + assert confidence_tier(1.0) == "high" + + def test_medium(self) -> None: + assert confidence_tier(0.5) == "medium" + assert confidence_tier(0.79) == "medium" + + def test_low(self) -> None: + assert confidence_tier(0.0) == "low" + assert confidence_tier(0.49) == "low" + + +# ============================================================================= +# YAML generation tests +# ============================================================================= + + +class TestGenerateYamlEntry: + def test_format(self, sample_entry: SNPublishEntry) -> None: + content = generate_yaml_entry(sample_entry) + doc = yaml.safe_load(content) + + assert doc["name"] == "electron_temperature" + assert doc["kind"] == "physical" + assert doc["unit"] == "eV" + assert doc["status"] == "candidate" + assert doc["description"] == "Electron temperature profile" + assert doc["provenance"]["source"] == "dd" + assert doc["provenance"]["confidence"] == 0.95 + assert doc["provenance"]["generated_by"] == "imas-codex" + + def test_all_fields_present(self, sample_entry: SNPublishEntry) -> None: + content = generate_yaml_entry(sample_entry) + doc = yaml.safe_load(content) + + # Required fields + assert "name" in doc + assert "kind" in doc + assert "status" in doc + assert "provenance" in doc + + # Provenance sub-fields + prov = doc["provenance"] + assert "source" in prov + assert "source_id" in prov + assert "confidence" in prov + assert "generated_by" in prov + + def test_optional_unit_omitted(self, sample_provenance: SNProvenance) -> None: + entry = SNPublishEntry( + name="test_name", + provenance=sample_provenance, + ) + content = generate_yaml_entry(entry) + doc = yaml.safe_load(content) + assert "unit" not in doc + + def test_optional_ids_name_omitted(self) -> None: + prov = SNProvenance(source="signals", source_id="sig:x", confidence=0.5) + entry = SNPublishEntry(name="test_name", provenance=prov) + content = generate_yaml_entry(entry) + doc = yaml.safe_load(content) + assert "ids_name" not in doc["provenance"] + + def test_tags_in_output(self, sample_entry: SNPublishEntry) -> None: + content = generate_yaml_entry(sample_entry) + doc = yaml.safe_load(content) + assert doc["tags"] == ["equilibrium", "core_profiles"] + + def test_empty_tags_omitted(self, sample_provenance: SNProvenance) -> None: + entry = SNPublishEntry( + name="test_name", + provenance=sample_provenance, + ) + content = generate_yaml_entry(entry) + doc = yaml.safe_load(content) + assert "tags" not in doc + + def test_roundtrip_yaml(self, sample_entry: SNPublishEntry) -> None: + """YAML output should parse back to the same values.""" + content = generate_yaml_entry(sample_entry) + doc = yaml.safe_load(content) + assert doc["name"] == sample_entry.name + assert doc["unit"] == sample_entry.unit + assert doc["provenance"]["confidence"] == sample_entry.provenance.confidence + + +class TestGenerateCatalogFiles: + def test_writes_files( + self, tmp_path: Path, sample_entries: list[SNPublishEntry] + ) -> None: + written = generate_catalog_files(sample_entries, tmp_path) + assert len(written) == len(sample_entries) + for path in written: + assert path.exists() + assert path.suffix == ".yaml" + + def test_filenames( + self, tmp_path: Path, sample_entries: list[SNPublishEntry] + ) -> None: + written = generate_catalog_files(sample_entries, tmp_path) + names = {p.stem for p in written} + expected = {e.name for e in sample_entries} + assert names == expected + + def test_file_content_valid_yaml( + self, tmp_path: Path, sample_entry: SNPublishEntry + ) -> None: + written = generate_catalog_files([sample_entry], tmp_path) + assert len(written) == 1 + with open(written[0]) as f: + doc = yaml.safe_load(f) + assert doc["name"] == "electron_temperature" + + def test_creates_output_dir( + self, tmp_path: Path, sample_entry: SNPublishEntry + ) -> None: + out = tmp_path / "nested" / "dir" + assert not out.exists() + generate_catalog_files([sample_entry], out) + assert out.is_dir() + + +# ============================================================================= +# Batching tests +# ============================================================================= + + +class TestBatchByGroup: + def test_batch_by_ids(self, sample_entries: list[SNPublishEntry]) -> None: + groups = batch_by_group(sample_entries, group_by="ids") + assert "equilibrium" in groups + assert "core_profiles" in groups + # major_radius has no ids_name → "unscoped" + assert "unscoped" in groups + assert len(groups["equilibrium"]) == 2 # electron_temperature + plasma_current + + def test_batch_by_domain(self, sample_entries: list[SNPublishEntry]) -> None: + groups = batch_by_group(sample_entries, group_by="domain") + assert "equilibrium" in groups + assert "core_profiles" in groups + + def test_batch_by_confidence(self, sample_entries: list[SNPublishEntry]) -> None: + groups = batch_by_group(sample_entries, group_by="confidence") + assert "high" in groups # 0.95, 0.88 + assert "medium" in groups # 0.72 + assert "low" in groups # 0.45 + + def test_empty_entries(self) -> None: + groups = batch_by_group([], group_by="ids") + assert groups == {} + + +class TestMakePublishBatches: + def test_creates_batches(self, sample_entries: list[SNPublishEntry]) -> None: + batches = make_publish_batches(sample_entries, group_by="ids") + assert len(batches) > 0 + assert all(isinstance(b, SNPublishBatch) for b in batches) + + def test_batch_confidence_tier(self, sample_entries: list[SNPublishEntry]) -> None: + batches = make_publish_batches(sample_entries, group_by="ids") + for batch in batches: + assert batch.confidence_tier in ("high", "medium", "low") + + def test_all_entries_accounted(self, sample_entries: list[SNPublishEntry]) -> None: + batches = make_publish_batches(sample_entries, group_by="ids") + total = sum(len(b.entries) for b in batches) + assert total == len(sample_entries) + + +# ============================================================================= +# Duplicate checking tests +# ============================================================================= + + +class TestCheckCatalogDuplicates: + def test_no_catalog_dir(self, sample_entries: list[SNPublishEntry]) -> None: + new, dupes = check_catalog_duplicates(sample_entries, catalog_dir=None) + assert len(new) == len(sample_entries) + assert len(dupes) == 0 + + def test_no_duplicates( + self, tmp_path: Path, sample_entries: list[SNPublishEntry] + ) -> None: + new, dupes = check_catalog_duplicates(sample_entries, catalog_dir=tmp_path) + assert len(new) == len(sample_entries) + assert len(dupes) == 0 + + def test_finds_catalog_duplicates( + self, tmp_path: Path, sample_entries: list[SNPublishEntry] + ) -> None: + # Write one existing catalog entry + (tmp_path / "electron_temperature.yaml").write_text( + yaml.safe_dump({"name": "electron_temperature", "kind": "physical"}) + ) + new, dupes = check_catalog_duplicates(sample_entries, catalog_dir=tmp_path) + assert len(dupes) == 1 + assert dupes[0].name == "electron_temperature" + assert len(new) == len(sample_entries) - 1 + + def test_finds_within_batch_duplicates( + self, sample_provenance: SNProvenance + ) -> None: + entries = [ + SNPublishEntry(name="dup_name", provenance=sample_provenance), + SNPublishEntry(name="dup_name", provenance=sample_provenance), + SNPublishEntry(name="unique_name", provenance=sample_provenance), + ] + new, dupes = check_catalog_duplicates(entries, catalog_dir=None) + assert len(new) == 2 # first dup_name + unique_name + assert len(dupes) == 1 # second dup_name + + def test_nonexistent_catalog_dir( + self, tmp_path: Path, sample_entries: list[SNPublishEntry] + ) -> None: + """Non-existent catalog dir should be treated as empty.""" + fake_dir = tmp_path / "nonexistent" + new, dupes = check_catalog_duplicates(sample_entries, catalog_dir=fake_dir) + assert len(new) == len(sample_entries) + + +# ============================================================================= +# Graph record conversion tests +# ============================================================================= + + +class TestGraphRecordsToEntries: + def test_schema_canonical_fields(self) -> None: + """Test conversion from schema-canonical property names.""" + records = [ + { + "name": "electron_temperature", + "description": "Te profile", + "source": "dd", + "source_path": "equilibrium/time_slice/profiles_1d/electrons/temperature", + "canonical_units": "eV", + "confidence": 0.9, + "ids_name": "equilibrium", + } + ] + entries = graph_records_to_entries(records) + assert len(entries) == 1 + e = entries[0] + assert e.name == "electron_temperature" + assert e.unit == "eV" + assert e.provenance.source == "dd" + assert e.provenance.source_id == ( + "equilibrium/time_slice/profiles_1d/electrons/temperature" + ) + assert e.provenance.confidence == 0.9 + assert e.provenance.ids_name == "equilibrium" + + def test_legacy_field_names(self) -> None: + """Test conversion from legacy write property names.""" + records = [ + { + "name": "plasma_current", + "description": "Ip", + "source_type": "dd", + "source_id": "equilibrium/global/ip", + "units": "A", + "confidence": None, + "ids_name": None, + } + ] + entries = graph_records_to_entries(records) + assert len(entries) == 1 + e = entries[0] + assert e.name == "plasma_current" + assert e.unit == "A" + assert e.provenance.source == "dd" + assert e.provenance.confidence == 1.0 # default for validated + + def test_empty_records(self) -> None: + assert graph_records_to_entries([]) == [] + + def test_skips_nameless_records(self) -> None: + records = [{"description": "orphan", "source": "dd"}] + entries = graph_records_to_entries(records) + assert len(entries) == 0 + + def test_tags_include_ids_name(self) -> None: + records = [ + { + "name": "foo", + "source": "dd", + "source_path": "x", + "confidence": 0.7, + "ids_name": "magnetics", + } + ] + entries = graph_records_to_entries(records) + assert "magnetics" in entries[0].tags + + def test_tags_empty_when_no_ids(self) -> None: + records = [ + { + "name": "bar", + "source": "signals", + "source_path": "y", + "confidence": 0.7, + "ids_name": None, + } + ] + entries = graph_records_to_entries(records) + assert entries[0].tags == [] diff --git a/tests/sn/test_review.py b/tests/sn/test_review.py new file mode 100644 index 000000000..1f1be6d8e --- /dev/null +++ b/tests/sn/test_review.py @@ -0,0 +1,438 @@ +"""Tests for the cross-model review phase of the SN build pipeline.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from imas_codex.sn.models import SNReviewBatch, SNReviewItem, SNReviewVerdict + +# ============================================================================= +# Model instantiation tests +# ============================================================================= + + +class TestSNReviewModels: + """Test review Pydantic model instantiation and validation.""" + + def test_review_verdict_enum_values(self): + """All three verdict values are valid.""" + assert SNReviewVerdict.accept == "accept" + assert SNReviewVerdict.reject == "reject" + assert SNReviewVerdict.revise == "revise" + + def test_review_item_accept(self): + """Accept verdict with minimal fields.""" + item = SNReviewItem( + source_id="equilibrium/time_slice/profiles_1d/psi", + standard_name="poloidal_flux", + verdict=SNReviewVerdict.accept, + confidence=0.95, + reason="Name correctly captures the physics quantity", + ) + assert item.verdict == SNReviewVerdict.accept + assert item.confidence == 0.95 + assert item.revised_name is None + assert item.revised_fields is None + assert item.issues == [] + + def test_review_item_reject(self): + """Reject verdict with issues.""" + item = SNReviewItem( + source_id="magnetics/flux_loop/flux/data", + standard_name="invalid_name", + verdict=SNReviewVerdict.reject, + confidence=0.8, + reason="Name does not represent a valid physics quantity", + issues=["Invalid physical_base", "No matching grammar rule"], + ) + assert item.verdict == SNReviewVerdict.reject + assert len(item.issues) == 2 + + def test_review_item_revise(self): + """Revise verdict with revised name and fields.""" + item = SNReviewItem( + source_id="core_profiles/profiles_1d/electrons/temperature", + standard_name="electron_temp", + verdict=SNReviewVerdict.revise, + confidence=0.85, + reason="Physical base should be 'temperature' not 'temp'", + revised_name="electron_temperature", + revised_fields={"physical_base": "temperature", "subject": "electron"}, + issues=["Abbreviated physical_base"], + ) + assert item.verdict == SNReviewVerdict.revise + assert item.revised_name == "electron_temperature" + assert item.revised_fields == { + "physical_base": "temperature", + "subject": "electron", + } + + def test_review_batch(self): + """SNReviewBatch wraps a list of review items.""" + batch = SNReviewBatch( + reviews=[ + SNReviewItem( + source_id="src1", + standard_name="electron_temperature", + verdict=SNReviewVerdict.accept, + confidence=0.95, + reason="Good", + ), + SNReviewItem( + source_id="src2", + standard_name="bad_name", + verdict=SNReviewVerdict.reject, + confidence=0.7, + reason="Invalid", + ), + ] + ) + assert len(batch.reviews) == 2 + assert batch.reviews[0].verdict == SNReviewVerdict.accept + assert batch.reviews[1].verdict == SNReviewVerdict.reject + + def test_review_item_confidence_bounds(self): + """Confidence must be between 0.0 and 1.0.""" + from pydantic import ValidationError + + # Valid at boundaries + SNReviewItem( + source_id="a", + standard_name="b", + verdict=SNReviewVerdict.accept, + confidence=0.0, + reason="c", + ) + SNReviewItem( + source_id="a", + standard_name="b", + verdict=SNReviewVerdict.accept, + confidence=1.0, + reason="c", + ) + + # Invalid: above 1.0 + with pytest.raises(ValidationError): + SNReviewItem( + source_id="a", + standard_name="b", + verdict=SNReviewVerdict.accept, + confidence=1.5, + reason="c", + ) + + # Invalid: below 0.0 + with pytest.raises(ValidationError): + SNReviewItem( + source_id="a", + standard_name="b", + verdict=SNReviewVerdict.accept, + confidence=-0.1, + reason="c", + ) + + def test_empty_review_batch(self): + """Empty review batch is valid.""" + batch = SNReviewBatch(reviews=[]) + assert batch.reviews == [] + + +# ============================================================================= +# Review worker tests +# ============================================================================= + + +class TestReviewWorker: + """Test the review_worker function.""" + + def _make_state(self, **overrides): + """Create a minimal SNBuildState for testing.""" + from imas_codex.sn.state import SNBuildState + + defaults = { + "facility": "dd", + "source": "dd", + "dry_run": False, + "skip_review": False, + } + defaults.update(overrides) + return SNBuildState(**defaults) + + def test_dry_run_skips_review(self): + """In dry-run mode, review passes candidates through unchanged.""" + state = self._make_state(dry_run=True) + state.composed = [ + { + "id": "electron_temperature", + "source_id": "path/a", + "physical_base": "temperature", + }, + {"id": "ion_density", "source_id": "path/b", "physical_base": "density"}, + ] + + from imas_codex.sn.workers import review_worker + + asyncio.get_event_loop().run_until_complete(review_worker(state)) + + assert state.review_phase.done + assert state.stats.get("review_skipped") is True + assert len(state.reviewed) == 2 + assert state.review_stats.total == 2 + assert state.review_stats.processed == 2 + + def test_empty_candidates_skip(self): + """No candidates → review completes with no-op.""" + state = self._make_state() + state.composed = [] + + from imas_codex.sn.workers import review_worker + + asyncio.get_event_loop().run_until_complete(review_worker(state)) + + assert state.review_phase.done + assert state.reviewed == [] + + @patch("imas_codex.sn.workers._review_batch") + @patch("imas_codex.sn.workers._get_existing_names_for_review") + def test_accept_verdict_passes_through(self, mock_existing, mock_batch): + """Accept verdict keeps the candidate in reviewed output.""" + mock_existing.return_value = set() + + candidates = [ + { + "id": "electron_temperature", + "source_id": "path/a", + "physical_base": "temperature", + }, + ] + + # Mock the batch to return the candidate as accepted + async def _mock_review(*args, **kwargs): + return candidates, 0, 0, 0.001, 100 + + mock_batch.side_effect = _mock_review + + state = self._make_state() + state.composed = list(candidates) + state.review_model = "test/model" + + from imas_codex.sn.workers import review_worker + + asyncio.get_event_loop().run_until_complete(review_worker(state)) + + assert state.review_phase.done + assert len(state.reviewed) == 1 + assert state.reviewed[0]["id"] == "electron_temperature" + assert state.stats["review_accepted"] == 1 + assert state.stats["review_rejected"] == 0 + + @patch("imas_codex.sn.workers._review_batch") + @patch("imas_codex.sn.workers._get_existing_names_for_review") + def test_reject_verdict_removes_candidate(self, mock_existing, mock_batch): + """Reject verdict removes the candidate from reviewed output.""" + mock_existing.return_value = set() + + candidates = [ + {"id": "bad_name", "source_id": "path/a", "physical_base": "x"}, + ] + + # Mock the batch to return empty accepted, 1 rejected + async def _mock_review(*args, **kwargs): + return [], 1, 0, 0.001, 100 + + mock_batch.side_effect = _mock_review + + state = self._make_state() + state.composed = list(candidates) + state.review_model = "test/model" + + from imas_codex.sn.workers import review_worker + + asyncio.get_event_loop().run_until_complete(review_worker(state)) + + assert state.review_phase.done + assert len(state.reviewed) == 0 + assert state.stats["review_rejected"] == 1 + + @patch("imas_codex.sn.workers._review_batch") + @patch("imas_codex.sn.workers._get_existing_names_for_review") + def test_revise_verdict_updates_candidate(self, mock_existing, mock_batch): + """Revise verdict updates candidate name in reviewed output.""" + mock_existing.return_value = set() + + original = { + "id": "electron_temp", + "source_id": "path/a", + "physical_base": "temp", + } + revised = { + "id": "electron_temperature", + "source_id": "path/a", + "physical_base": "temperature", + } + + # Mock the batch to return revised candidate + async def _mock_review(*args, **kwargs): + return [revised], 0, 1, 0.001, 100 + + mock_batch.side_effect = _mock_review + + state = self._make_state() + state.composed = [dict(original)] + state.review_model = "test/model" + + from imas_codex.sn.workers import review_worker + + asyncio.get_event_loop().run_until_complete(review_worker(state)) + + assert state.review_phase.done + assert len(state.reviewed) == 1 + assert state.reviewed[0]["id"] == "electron_temperature" + assert state.stats["review_revised"] == 1 + + @patch("imas_codex.sn.workers._review_batch") + @patch("imas_codex.sn.workers._get_existing_names_for_review") + def test_batch_failure_passes_through(self, mock_existing, mock_batch): + """On batch failure, candidates pass through unreviewed.""" + mock_existing.return_value = set() + + candidates = [ + { + "id": "electron_temperature", + "source_id": "path/a", + "physical_base": "temperature", + }, + ] + + # Mock the batch to raise an exception + async def _mock_review(*args, **kwargs): + raise RuntimeError("LLM call failed") + + mock_batch.side_effect = _mock_review + + state = self._make_state() + state.composed = list(candidates) + state.review_model = "test/model" + + from imas_codex.sn.workers import review_worker + + asyncio.get_event_loop().run_until_complete(review_worker(state)) + + assert state.review_phase.done + # On failure, candidates pass through + assert len(state.reviewed) == 1 + assert state.review_stats.errors == 1 + + +# ============================================================================= +# State tests +# ============================================================================= + + +class TestSNBuildStateReview: + """Test review-related state fields.""" + + def test_state_has_review_fields(self): + """SNBuildState includes review configuration fields.""" + from imas_codex.sn.state import SNBuildState + + state = SNBuildState(facility="dd") + assert state.skip_review is False + assert state.review_model is None + assert state.reviewed == [] + assert state.review_phase.name == "review" + assert not state.review_phase.done + + def test_total_cost_includes_review(self): + """total_cost sums compose and review costs.""" + from imas_codex.sn.state import SNBuildState + + state = SNBuildState(facility="dd") + state.compose_stats.cost = 0.5 + state.review_stats.cost = 0.3 + assert state.total_cost == pytest.approx(0.8) + + def test_skip_review_configuration(self): + """skip_review can be set at construction.""" + from imas_codex.sn.state import SNBuildState + + state = SNBuildState(facility="dd", skip_review=True, review_model="test/model") + assert state.skip_review is True + assert state.review_model == "test/model" + + +# ============================================================================= +# Pipeline wiring tests +# ============================================================================= + + +class TestPipelineReviewWiring: + """Test that the pipeline correctly wires the review phase.""" + + def test_validate_depends_on_review_phase(self): + """Validate worker should depend on review_phase, not compose_phase.""" + # We can't easily test the actual pipeline running without graph, + # but we can verify the WorkerSpec construction. + from imas_codex.sn.state import SNBuildState + + state = SNBuildState(facility="dd", skip_review=False) + + # When skip_review is False, review_phase should not be done yet + assert not state.review_phase.done + assert not state.validate_phase.done + + def test_skip_review_allows_validate(self): + """When review is skipped, validate can still proceed. + + The engine marks disabled phases as done, so validate's + dependency on review_phase is satisfied. + """ + from imas_codex.discovery.base.engine import WorkerSpec + from imas_codex.sn.state import SNBuildState + from imas_codex.sn.workers import review_worker, validate_worker + + state = SNBuildState(facility="dd", skip_review=True) + + review_spec = WorkerSpec( + "review", + "review_phase", + review_worker, + depends_on=["compose_phase"], + enabled=not state.skip_review, + ) + + validate_spec = WorkerSpec( + "validate", + "validate_phase", + validate_worker, + depends_on=["review_phase"], + ) + + # When review is disabled, the engine would mark review_phase done + assert review_spec.enabled is False + assert validate_spec.depends_on == ["review_phase"] + + # Simulate engine marking disabled phase done + state.review_phase.mark_done() + assert state.review_phase.done + + def test_validate_reads_reviewed_buffer(self): + """Validate worker reads from state.reviewed when populated.""" + from imas_codex.sn.state import SNBuildState + + state = SNBuildState(facility="dd", dry_run=True) + state.reviewed = [ + {"id": "electron_temperature", "source_id": "a"}, + ] + state.composed = [ + {"id": "old_name", "source_id": "b"}, + ] + + from imas_codex.sn.workers import validate_worker + + # In dry-run, validation is skipped — but we verify the buffer logic + asyncio.get_event_loop().run_until_complete(validate_worker(state)) + assert state.validate_phase.done diff --git a/tests/tools/test_dd_version_and_error_fields.py b/tests/tools/test_dd_version_and_error_fields.py index e0243363a..9522f192e 100644 --- a/tests/tools/test_dd_version_and_error_fields.py +++ b/tests/tools/test_dd_version_and_error_fields.py @@ -28,7 +28,7 @@ async def test_shared_fetch_dd_error_fields_returns_structured_results(): ] tool = GraphPathTool(gc) - result = await tool.fetch_dd_error_fields("equilibrium/time_slice/profiles_1d/psi") + result = await tool.fetch_error_fields("equilibrium/time_slice/profiles_1d/psi") assert result["path"] == "equilibrium/time_slice/profiles_1d/psi" assert result["count"] == 1 @@ -42,7 +42,7 @@ async def test_shared_fetch_dd_error_fields_returns_not_found(): gc.query.return_value = [] tool = GraphPathTool(gc) - result = await tool.fetch_dd_error_fields("fake/path") + result = await tool.fetch_error_fields("fake/path") assert result["path"] == "fake/path" assert result["count"] == 0 diff --git a/tests/tools/test_dd_version_filtering.py b/tests/tools/test_dd_version_filtering.py index e2dc89b83..9debfda1f 100644 --- a/tests/tools/test_dd_version_filtering.py +++ b/tests/tools/test_dd_version_filtering.py @@ -87,16 +87,17 @@ class TestRenamedPathHandling: async def test_renamed_path_returns_valid_model(self): """Renamed paths must produce a valid CheckPathsResultItem, not a Pydantic error.""" gc = MagicMock() - # First query: path not found (no match) - # Second query: RENAMED_TO found - gc.query.side_effect = [ - [], # path lookup returns empty - [ - { - "old_path": "magnetics/bpol_probe/polarisation_angle", - "new_path": "magnetics/bpol_probe/polarization_angle", - } - ], + # Batch UNWIND query: path not found directly, but rename exists + gc.query.return_value = [ + { + "check_path": "magnetics/bpol_probe/polarisation_angle", + "id": None, + "ids": None, + "data_type": None, + "units": None, + "renamed_from": "magnetics/bpol_probe/polarisation_angle", + "renamed_to": "magnetics/bpol_probe/polarization_angle", + } ] tool = GraphPathTool(gc) result = await tool.check_dd_paths("magnetics/bpol_probe/polarisation_angle") @@ -128,10 +129,13 @@ async def test_dd3_path_found_with_dd_version_4(self): gc = MagicMock() gc.query.return_value = [ { + "check_path": "equilibrium/time_slice/profiles_1d/psi", "id": "equilibrium/time_slice/profiles_1d/psi", "ids": "equilibrium", "data_type": "FLT_1D", "units": "Wb", + "renamed_from": None, + "renamed_to": None, } ] tool = GraphPathTool(gc) @@ -149,7 +153,15 @@ async def test_dd_version_param_is_integer(self): """The dd_major_version parameter passed to Cypher must be an integer.""" gc = MagicMock() gc.query.return_value = [ - {"id": "test/path", "ids": "test", "data_type": "FLT_0D", "units": ""} + { + "check_path": "test/path", + "id": "test/path", + "ids": "test", + "data_type": "FLT_0D", + "units": "", + "renamed_from": None, + "renamed_to": None, + } ] tool = GraphPathTool(gc) await tool.check_dd_paths("test/path", dd_version=4) @@ -164,7 +176,15 @@ async def test_no_dd_version_skips_filter(self): """When dd_version is None, no version filter clause should be in the query.""" gc = MagicMock() gc.query.return_value = [ - {"id": "test/path", "ids": "test", "data_type": "FLT_0D", "units": ""} + { + "check_path": "test/path", + "id": "test/path", + "ids": "test", + "data_type": "FLT_0D", + "units": "", + "renamed_from": None, + "renamed_to": None, + } ] tool = GraphPathTool(gc) await tool.check_dd_paths("test/path", dd_version=None) @@ -226,7 +246,7 @@ def test_search_by_path_with_scope_and_dd_version(self): assert kwargs["dd_major_version"] == 4 @pytest.mark.asyncio - async def test_search_dd_clusters_path_with_scope(self): + async def test_search_imas_clusters_path_with_scope(self): """Full tool call with scope must not raise.""" gc = MagicMock() gc.query.return_value = [] @@ -247,7 +267,7 @@ async def test_search_dd_clusters_path_with_scope(self): class TestOverviewQueryStructure: - """Test that get_dd_overview uses correct query patterns.""" + """Test that get_imas_overview uses correct query patterns.""" @pytest.mark.asyncio async def test_overview_queries_ids_nodes(self): @@ -309,38 +329,38 @@ class TestExportQueryStructure: @pytest.mark.asyncio async def test_export_ids_uses_ids_filter(self): - """export_imas_ids must filter by IDS name.""" + """export_dd_ids must filter by IDS name.""" gc = MagicMock() gc.query.return_value = [] tool = GraphStructureTool(gc) - await tool.export_imas_ids("equilibrium") + await tool.export_dd_ids("equilibrium") cypher = gc.query.call_args[0][0] assert "p.ids = $ids_name" in cypher @pytest.mark.asyncio async def test_export_domain_uses_domain_filter(self): - """export_imas_domain must filter by physics domain.""" + """export_dd_domain must filter by physics domain.""" gc = MagicMock() gc.query.side_effect = [ [], # export results ] tool = GraphStructureTool(gc) - await tool.export_imas_domain("equilibrium") + await tool.export_dd_domain("equilibrium") export_cypher = gc.query.call_args_list[-1][0][0] assert "physics_domain" in export_cypher @pytest.mark.asyncio async def test_export_ids_with_dd_version(self): - """export_imas_ids with dd_version must include version filter.""" + """export_dd_ids with dd_version must include version filter.""" gc = MagicMock() gc.query.return_value = [] tool = GraphStructureTool(gc) - await tool.export_imas_ids("equilibrium", dd_version=4) + await tool.export_dd_ids("equilibrium", dd_version=4) cypher = gc.query.call_args[0][0] assert "INTRODUCED_IN" in cypher @@ -349,12 +369,12 @@ async def test_export_ids_with_dd_version(self): # ============================================================================ -# Phase 3: list_dd_paths query tests +# Phase 3: list_imas_paths query tests # ============================================================================ class TestListPathsQuery: - """Verify list_dd_paths query patterns.""" + """Verify list_imas_paths query patterns.""" @pytest.mark.asyncio async def test_ids_level_queries_graph(self): @@ -392,8 +412,7 @@ async def test_ids_level_returns_results(self): gc = MagicMock() gc.query.side_effect = [ [{"i.name": "equilibrium"}], # IDS exists - [], # STARTS WITH query - [ # ids = query (overwrites) + [ # ids = query (unified) {"id": "equilibrium/time_slice"}, {"id": "equilibrium/vacuum_toroidal_field"}, ], diff --git a/tests/tools/test_facade_delegation.py b/tests/tools/test_facade_delegation.py index dd2343e0f..e88686ed5 100644 --- a/tests/tools/test_facade_delegation.py +++ b/tests/tools/test_facade_delegation.py @@ -25,7 +25,7 @@ "search_tool": (GraphSearchTool, ["search_dd_paths"]), "path_tool": ( GraphPathTool, - ["check_dd_paths", "fetch_dd_paths", "fetch_dd_error_fields"], + ["check_dd_paths", "fetch_dd_paths", "fetch_error_fields"], ), "list_tool": (GraphListTool, ["list_dd_paths"]), "overview_tool": (GraphOverviewTool, ["get_dd_overview"]), @@ -36,9 +36,8 @@ GraphStructureTool, [ "analyze_dd_structure", - "get_cocos_fields", - "export_imas_ids", - "export_imas_domain", + "export_dd_ids", + "export_dd_domain", ], ), "version_tool": ( @@ -83,8 +82,8 @@ def test_no_facade_methods_on_tools(self): "get_dd_identifiers", "get_dd_path_context", "get_dd_cocos_fields", - "export_imas_ids", - "export_imas_domain", + "export_dd_ids", + "export_dd_domain", "get_dd_versions", "search_dd_clusters", "get_dd_version_context", diff --git a/uv.lock b/uv.lock index e845d80de..7f470523a 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d2/c581486aa6c4fbd7394c23c47b83fa1a919d34194e16944241daf9e762dd/accelerate-1.12.0-py3-none-any.whl", hash = "sha256:3e2091cd341423207e2f084a6654b1efcd250dc326f2a37d6dde446e07cabb11", size = 380935, upload-time = "2025-11-21T11:27:44.522Z" }, ] +[[package]] +name = "ag-ui-protocol" +version = "0.1.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/71/96c21ae7e2fb9b610c1a90d38bd2de8b6e5b2900a63001f3882f43e519af/ag_ui_protocol-0.1.15.tar.gz", hash = "sha256:5e23c1042c7d4e364d685e68d2fb74d37c16bc83c66d270102d8eaedce56ad82", size = 6269, upload-time = "2026-04-01T15:44:33.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/a0/a73398d30bb0f9ad70cd70426151a4a19527a7296e48a3a16a50e1d5db05/ag_ui_protocol-0.1.15-py3-none-any.whl", hash = "sha256:85cde077023ccbc37b5ce2ad953537883c262d210320f201fc2ec4e85408b06a", size = 8661, upload-time = "2026-04-01T15:44:32.079Z" }, +] + [[package]] name = "aiofile" version = "3.9.0" @@ -132,6 +144,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.92.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/2d/fc5c5a369db977efbaa646d77ba42b38a6de4e95789884032b0e2e3fc834/anthropic-0.92.0.tar.gz", hash = "sha256:d1e792ed0692379452a1af6b266df495e973c3695cd0aace2a108b838393cbc4", size = 652420, upload-time = "2026-04-08T16:55:35.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/21/bf5b5ab10b6932c5c43eaa66b6e3f256de569cf0323d89f9cc281a0d0f39/anthropic-0.92.0-py3-none-any.whl", hash = "sha256:f92a4bd065d5cab90a96b65bb44e473bf7c6fe731a743cd156e9ad1d245c381e", size = 621195, upload-time = "2026-04-08T16:55:33.639Z" }, +] + [[package]] name = "antlr4-python3-runtime" version = "4.9.3" @@ -160,6 +191,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, ] +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + [[package]] name = "arrow" version = "1.4.0" @@ -311,6 +351,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "boto3" +version = "1.42.85" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/9d/a9a7b5a9351e3ff0baae01136f71ba6fc4652fe0dc2da3b0a8ebdfc1be44/boto3-1.42.85.tar.gz", hash = "sha256:1cd3dcbfaba85c6071ba9397c1804b6a94a1a97031b8f1993fdba27c0c5d6eba", size = 112769, upload-time = "2026-04-07T19:40:53.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/ab/3167b8ec3cf1d87ad08d2ad5f15823a22945cae7870798274c283c3a18f1/boto3-1.42.85-py3-none-any.whl", hash = "sha256:4f6ac066e41d18ec33f532253fac0f35e0fdca373724458f983ce3d531340b7a", size = 140556, upload-time = "2026-04-07T19:40:52.186Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.85" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/ac/7f14b05cf43e4baae99f4570b02e10b2aebf242dfd86245523340390c834/botocore-1.42.85.tar.gz", hash = "sha256:2ee61f80b7724a143e16d0a85408ef5fa20b99dce7a3c8ec5d25cc8dced164c1", size = 15159562, upload-time = "2026-04-07T19:40:43.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/f3/c1fbaff4c509c616fd01f44357283a8992f10b3a05d932b22e602aa3a221/botocore-1.42.85-py3-none-any.whl", hash = "sha256:828b67722caeb7e240eefedee74050e803d1fa102958ead9c4009101eefd5381", size = 14839741, upload-time = "2026-04-07T19:40:40.733Z" }, +] + [[package]] name = "build" version = "1.4.0" @@ -443,6 +511,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] +[[package]] +name = "cohere" +version = "5.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastavro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "requests" }, + { name = "tokenizers" }, + { name = "types-requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/75/4c346f6e2322e545f8452692304bd4eca15a2a0209ab9af6a0d1a7810b67/cohere-5.21.1.tar.gz", hash = "sha256:e5ade4423b928b01ff2038980e1b62b2a5bb412c8ab83e30882753b810a5509f", size = 191272, upload-time = "2026-03-26T15:09:27.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/50/5538f02ec6d10fbb84f29c1b18c68ff2a03d7877926a80275efdf8755a9f/cohere-5.21.1-py3-none-any.whl", hash = "sha256:f15592ec60d8cf12f01563db94ec28c388c61269d9617f23c2d6d910e505344e", size = 334262, upload-time = "2026-03-26T15:09:26.284Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -631,6 +718,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + [[package]] name = "email-validator" version = "2.3.0" @@ -653,6 +751,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] +[[package]] +name = "eval-type-backport" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/a3/cafafb4558fd638aadfe4121dc6cefb8d743368c085acb2f521df0f3d9d7/eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed", size = 9445, upload-time = "2025-12-02T11:51:42.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063, upload-time = "2025-12-02T11:51:41.665Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -714,6 +821,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, ] +[[package]] +name = "fastavro" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/8b/fa2d3287fd2267be6261d0177c6809a7fa12c5600ddb33490c8dc29e77b2/fastavro-1.12.1.tar.gz", hash = "sha256:2f285be49e45bc047ab2f6bed040bb349da85db3f3c87880e4b92595ea093b2b", size = 1025661, upload-time = "2025-10-10T15:40:55.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f0/10bd1a3d08667fa0739e2b451fe90e06df575ec8b8ba5d3135c70555c9bd/fastavro-1.12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:509818cb24b98a804fc80be9c5fed90f660310ae3d59382fc811bfa187122167", size = 1009057, upload-time = "2025-10-10T15:41:24.556Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/0d985bc99e1fa9e74c636658000ba38a5cd7f5ab2708e9c62eaf736ecf1a/fastavro-1.12.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089e155c0c76e0d418d7e79144ce000524dd345eab3bc1e9c5ae69d500f71b14", size = 3391866, upload-time = "2025-10-10T15:41:26.882Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/b4951dc84ebc34aac69afcbfbb22ea4a91080422ec2bfd2c06076ff1d419/fastavro-1.12.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44cbff7518901c91a82aab476fcab13d102e4999499df219d481b9e15f61af34", size = 3458005, upload-time = "2025-10-10T15:41:29.017Z" }, + { url = "https://files.pythonhosted.org/packages/af/f8/5a8df450a9f55ca8441f22ea0351d8c77809fc121498b6970daaaf667a21/fastavro-1.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a275e48df0b1701bb764b18a8a21900b24cf882263cb03d35ecdba636bbc830b", size = 3295258, upload-time = "2025-10-10T15:41:31.564Z" }, + { url = "https://files.pythonhosted.org/packages/99/b2/40f25299111d737e58b85696e91138a66c25b7334f5357e7ac2b0e8966f8/fastavro-1.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2de72d786eb38be6b16d556b27232b1bf1b2797ea09599507938cdb7a9fe3e7c", size = 3430328, upload-time = "2025-10-10T15:41:33.689Z" }, + { url = "https://files.pythonhosted.org/packages/e0/07/85157a7c57c5f8b95507d7829b5946561e5ee656ff80e9dd9a757f53ddaf/fastavro-1.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:9090f0dee63fe022ee9cc5147483366cc4171c821644c22da020d6b48f576b4f", size = 444140, upload-time = "2025-10-10T15:41:34.902Z" }, +] + [[package]] name = "fastjsonschema" version = "2.21.2" @@ -850,6 +971,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] +[[package]] +name = "genai-prices" +version = "0.0.56" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/6b/94b3018a672c7775edfb485f0fed8f6068fba75e49b067e8a1ac5eb96764/genai_prices-0.0.56.tar.gz", hash = "sha256:ac24b16a84d0ab97539bfa48dfa4649689de8e3ce71c12ebacef29efb1998045", size = 65872, upload-time = "2026-03-20T20:33:00.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/f6/8ef7e4c286deb2709d11ca96a5237caae3ef4876ab3c48095856cfd2df30/genai_prices-0.0.56-py3-none-any.whl", hash = "sha256:dbe86be8f3f556bed1b72209ed36851fec8b01793b3b220f42921a4e7da945f6", size = 68966, upload-time = "2026-03-20T20:33:02.555Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.71.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/49/a13e9cf4d963691fc79d661f2d78f041bc1f2e7287d41ef0f831b82462f0/google_genai-1.71.0.tar.gz", hash = "sha256:044f7ac453437d5d380ec192f823dba64e001c478d7878c5a2d327432f4a28ac", size = 520044, upload-time = "2026-04-08T17:55:51.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/d4/63c97d487f0b4861a6f530628a9e56a380f9c9704d2e207f0bed9d16e31a/google_genai-1.71.0-py3-none-any.whl", hash = "sha256:6213ebfee7fc8e6a21692c2c340309e463322cc35c2c603d1ea59e8ea34ac240", size = 760561, upload-time = "2026-04-08T17:55:49.107Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, +] + [[package]] name = "graphviz" version = "0.21" @@ -876,6 +1061,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + +[[package]] +name = "groq" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/c7/a2153b639062f59f9bc93a1b5507c0c4a6b654b8a9edbf432ec2f4a62d2d/groq-1.1.2.tar.gz", hash = "sha256:9ec2b5b6a1c4856a8c6c38741353c5ab37472a4e3fded02af783750d849cc988", size = 154033, upload-time = "2026-03-25T23:16:10.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/b0/83e3892a4597a4b8ebf8a662aeaf314765c4c2340516eb1d049b459b24fc/groq-1.1.2-py3-none-any.whl", hash = "sha256:348cb7a674b6aa7105719b533f6fc48fd32b503bc9256924aaed6dc186f778b5", size = 141700, upload-time = "2026-03-25T23:16:08.998Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -912,17 +1144,18 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.2.0" +version = "1.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" }, - { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" }, - { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" }, - { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, ] [[package]] @@ -964,21 +1197,22 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "0.36.2" +version = "1.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' or (extra == 'extra-10-imas-codex-cpu' and extra == 'extra-10-imas-codex-gpu') or (extra == 'extra-10-imas-codex-gpu' and extra == 'extra-10-imas-codex-test')" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' or (extra == 'extra-10-imas-codex-cpu' and extra == 'extra-10-imas-codex-gpu') or (extra == 'extra-10-imas-codex-gpu' and extra == 'extra-10-imas-codex-test')" }, + { name = "httpx" }, { name = "packaging" }, { name = "pyyaml" }, - { name = "requests" }, { name = "tqdm" }, + { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/65/fb800d327bf25bf31b798dd08935d326d064ecb9b359059fecd91b3a98e8/huggingface_hub-1.9.2.tar.gz", hash = "sha256:8d09d080a186bd950a361bfc04b862dfb04d6a2b41d48e9ba1b37507cfd3f1e1", size = 750284, upload-time = "2026-04-08T08:43:11.127Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, + { url = "https://files.pythonhosted.org/packages/57/d4/e33bf0b362810a9b96c5923e38908950d58ecb512db42e3730320c7f4a3a/huggingface_hub-1.9.2-py3-none-any.whl", hash = "sha256:e1e62ce237d4fbeca9f970aeb15176fbd503e04c25577bfd22f44aa7aa2b5243", size = 637349, upload-time = "2026-04-08T08:43:09.114Z" }, ] [[package]] @@ -1076,6 +1310,7 @@ dev = [ { name = "fastapi" }, { name = "hdbscan" }, { name = "imas-python" }, + { name = "imas-standard-names" }, { name = "ipykernel" }, { name = "ipython" }, { name = "jellyfish" }, @@ -1164,6 +1399,7 @@ dev = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "hdbscan", specifier = ">=0.8.41" }, { name = "imas-python", specifier = ">=2.0.1" }, + { name = "imas-standard-names", editable = "../imas-standard-names" }, { name = "ipykernel", specifier = ">=6.29.5" }, { name = "ipython", specifier = ">=9.2.0" }, { name = "jellyfish", specifier = ">=1.2.1" }, @@ -1240,6 +1476,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/c3/51724c1ba79aa3f34566750de5a0ad41176a272b19e0585dc62aea3a987b/imas_python-2.2.0-py3-none-any.whl", hash = "sha256:52a16cd13d7756413ff918c0cf754d42ab9ac61ae2524ab7f72a9df00a70637c", size = 2405647, upload-time = "2026-02-12T15:32:16.657Z" }, ] +[[package]] +name = "imas-standard-names" +source = { editable = "../imas-standard-names" } +dependencies = [ + { name = "click" }, + { name = "dotenv" }, + { name = "fastmcp" }, + { name = "markdown" }, + { name = "pint" }, + { name = "pydantic" }, + { name = "pydantic-ai" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "strictyaml" }, + { name = "textual" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.8,<9.0.0" }, + { name = "dotenv", specifier = ">=0.9.9,<0.10.0" }, + { name = "en-core-web-sm", marker = "extra == 'quality'", url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" }, + { name = "fastmcp", specifier = "~=3.2.0" }, + { name = "markdown", specifier = ">=3.8,<4.0" }, + { name = "mike", marker = "extra == 'docs'", specifier = ">=2.1.3,<3.0.0" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6.1,<2.0.0" }, + { name = "mkdocs-data-plugin", marker = "extra == 'docs'", specifier = ">=0.2.0,<0.3.0" }, + { name = "mkdocs-include-markdown-plugin", marker = "extra == 'docs'", specifier = ">=7.0.0,<8.0.0" }, + { name = "mkdocs-macros-plugin", marker = "extra == 'docs'", specifier = ">=1.0.4,<2.0.0" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.6.5,<10.0.0" }, + { name = "mkdocs-table-reader-plugin", marker = "extra == 'docs'", specifier = ">=3.1.0,<4.0.0" }, + { name = "pint", specifier = ">=0.24.4,<0.25.0" }, + { name = "proselint", marker = "extra == 'quality'", specifier = ">=0.14.0,<0.15.0" }, + { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, + { name = "pydantic-ai", specifier = ">=1.56.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4,<9.0.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.1.0,<5.0.0" }, + { name = "pytest-html", marker = "extra == 'test'", specifier = ">=4.1.1,<5.0.0" }, + { name = "pyyaml", specifier = ">=6.0.2,<7.0.0" }, + { name = "requests", specifier = ">=2.33.0,<3.0.0" }, + { name = "ruff", marker = "extra == 'test'", specifier = ">=0.9.8,<1.0.0" }, + { name = "spacy", marker = "extra == 'quality'", specifier = ">=3.8.0,<4.0.0" }, + { name = "strictyaml", specifier = ">=1.7.3,<2.0.0" }, + { name = "textual", specifier = ">=6.1.0" }, +] +provides-extras = ["docs", "quality", "test"] + +[package.metadata.requires-dev] +dev = [ + { name = "en-core-web-sm", url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" }, + { name = "ipykernel", specifier = ">=6.29.5,<7.0.0" }, + { name = "logfire", specifier = ">=4.16.0,<5.0.0" }, + { name = "mcp-cli", specifier = ">=0.1.0,<1.0.0" }, + { name = "pandas", specifier = ">=2.2.3,<3.0.0" }, + { name = "pandas-stubs", specifier = ">=2.2.3.250308,<3.0.0" }, + { name = "pre-commit", specifier = ">=4.1.0,<5.0.0" }, + { name = "proselint", specifier = ">=0.14.0,<0.15.0" }, + { name = "pytest", specifier = ">=8.3.4,<9.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0,<5.0.0" }, + { name = "pytest-html", specifier = ">=4.1.1,<5.0.0" }, + { name = "ruff", specifier = ">=0.11.10,<1.0.0" }, + { name = "spacy", specifier = ">=3.8.0,<4.0.0" }, + { name = "textual-dev", specifier = ">=1.7.0" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20241230,<7.0.0" }, +] + [[package]] name = "importlib-metadata" version = "8.7.1" @@ -1466,6 +1768,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "joblib" version = "1.5.3" @@ -1518,6 +1829,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/90/0d93963711f811efe528e3cead2f2bfb78c196df74d8a24fe8d655288e50/jsonasobj2-1.0.4-py3-none-any.whl", hash = "sha256:12e86f86324d54fcf60632db94ea74488d5314e3da554c994fe1e2c6f29acb79", size = 6324, upload-time = "2021-06-02T17:43:27.126Z" }, ] +[[package]] +name = "jsonpath-python" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/db/2f4ecc24da35c6142b39c353d5b7c16eef955cc94b35a48d3fa47996d7c3/jsonpath_python-1.1.5.tar.gz", hash = "sha256:ceea2efd9e56add09330a2c9631ea3d55297b9619348c1055e5bfb9cb0b8c538", size = 87352, upload-time = "2026-03-17T06:16:40.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" }, +] + [[package]] name = "jsonpointer" version = "3.0.0" @@ -1657,6 +1977,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "linkml" version = "1.9.3" @@ -1739,6 +2071,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/f3/fffb7932870163cea7addc392165647a9a8a5489967de486c854226f1141/litellm-1.81.13-py3-none-any.whl", hash = "sha256:ae4aea2a55e85993f5f6dd36d036519422d24812a1a3e8540d9e987f2d7a4304", size = 14587505, upload-time = "2026-02-17T02:00:44.22Z" }, ] +[[package]] +name = "logfire" +version = "4.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/fc/21f923243d8c3ca2ebfa97de46970ced734e66ac634c1c35b6abb41300f1/logfire-4.31.0.tar.gz", hash = "sha256:361bfda17c9d70ada5d220211033bae06b871ddac9d5b06978bc0ceca6b8e658", size = 1080609, upload-time = "2026-03-27T19:00:46.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/1a/8c860e35bf847ac0d647d94bad89dccbb66cbcafdd61d8334f8cc7cfdd58/logfire-4.31.0-py3-none-any.whl", hash = "sha256:49fad38b5e6f199a98e9c8814e860c8a42595bb81479b52a20413e53ee475b72", size = 308896, upload-time = "2026-03-27T19:00:43.107Z" }, +] + +[package.optional-dependencies] +httpx = [ + { name = "opentelemetry-instrumentation-httpx" }, +] + +[[package]] +name = "logfire-api" +version = "4.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/8d5a3c1c282d5f2bd9f5e9ddd5288d1414a53301ce389af9016b6d82bd50/logfire_api-4.31.0.tar.gz", hash = "sha256:fc4b01257ebd4ce297ad374ed201eb1a9213b999f6ae6df45cfca5bd0ef378f8", size = 77838, upload-time = "2026-03-27T19:00:47.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/27/9372b7492b3e146908d520f8599909311cd930175801ad219171fafc6f3e/logfire_api-4.31.0-py3-none-any.whl", hash = "sha256:3c1f502fd4eb8ef0996427a5cf275fd8f327f38600650a1f53071a8171c812db", size = 123402, upload-time = "2026-03-27T19:00:44.952Z" }, +] + [[package]] name = "lxml" version = "6.0.2" @@ -1765,6 +2129,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1777,6 +2150,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] +plugins = [ + { name = "mdit-py-plugins" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1833,6 +2214,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1842,6 +2235,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mistralai" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eval-type-backport" }, + { name = "httpx" }, + { name = "jsonpath-python" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/96/b8ab9bbdcefda9803cf3b51e11548730ac94303850028fb86c163472aac3/mistralai-2.3.1.tar.gz", hash = "sha256:02989e509124cb28aaffd92660bf7511b3f8f5c215e1de8d49d0c8276bacc72a", size = 390323, upload-time = "2026-04-07T14:49:18.38Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/1d/b0da235154e9c7039c27b91785ec81f01ba1b3c48092924c6770ba2da22a/mistralai-2.3.1-py3-none-any.whl", hash = "sha256:8f4f783cb7603f6060490105f55b16a5d0a7e854c05e96fed316efcc4b393fe3", size = 930912, upload-time = "2026-04-07T14:49:16.863Z" }, +] + [[package]] name = "more-itertools" version = "10.8.0" @@ -1962,6 +2374,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] +[[package]] +name = "nexus-rpc" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/d5/cd1ffb202b76ebc1b33c1332a3416e55a39929006982adc2b1eb069aaa9b/nexus_rpc-1.4.0.tar.gz", hash = "sha256:3b8b373d4865671789cc43623e3dc0bcbf192562e40e13727e17f1c149050fba", size = 82367, upload-time = "2026-02-25T22:01:34.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/52/6327a5f4fda01207205038a106a99848a41c83e933cd23ea2cab3d2ebc6c/nexus_rpc-1.4.0-py3-none-any.whl", hash = "sha256:14c953d3519113f8ccec533a9efdb6b10c28afef75d11cdd6d422640c40b3a49", size = 29645, upload-time = "2026-02-25T22:01:33.122Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -2111,7 +2535,7 @@ wheels = [ [[package]] name = "openai" -version = "2.21.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2123,9 +2547,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, ] [[package]] @@ -2165,13 +2589,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/08/11208bcfcab4fc2023252c3f322aa397fd9ad948355fea60f5fc98648603/opentelemetry_instrumentation_httpx-0.60b1.tar.gz", hash = "sha256:a506ebaf28c60112cbe70ad4f0338f8603f148938cb7b6794ce1051cd2b270ae", size = 20611, upload-time = "2025-12-11T13:37:01.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/59/b98e84eebf745ffc75397eaad4763795bff8a30cbf2373a50ed4e70646c5/opentelemetry_instrumentation_httpx-0.60b1-py3-none-any.whl", hash = "sha256:f37636dd742ad2af83d896ba69601ed28da51fa4e25d1ab62fde89ce413e275b", size = 15701, upload-time = "2025-12-11T13:36:04.56Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/fc/c47bb04a1d8a941a4061307e1eddfa331ed4d0ab13d8a9781e6db256940a/opentelemetry_util_http-0.60b1.tar.gz", hash = "sha256:0d97152ca8c8a41ced7172d29d3622a219317f74ae6bb3027cfbdcf22c3cc0d6", size = 11053, upload-time = "2025-12-11T13:37:25.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" }, +] + [[package]] name = "packaging" -version = "26.0" +version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] @@ -2369,6 +2902,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -2437,6 +2985,27 @@ memory = [ { name = "cachetools" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2466,6 +3035,106 @@ email = [ { name = "email-validator" }, ] +[[package]] +name = "pydantic-ai" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "spec", "temporal", "ui", "vertexai", "xai"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/e1/ced6f04f60accb11deb1a8ca4fc576270022e646f011a9e1674695420710/pydantic_ai-1.78.0.tar.gz", hash = "sha256:dd3f56306c671f7785126e78d72924e5a80c30bca27460081941ad22b63fcc8d", size = 12645, upload-time = "2026-04-08T05:20:34.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/1a/497fdf8224aed69752559350e437395266ff0f3107ca3bff63b6925358cc/pydantic_ai-1.78.0-py3-none-any.whl", hash = "sha256:aa0fdacec813fa457243206a9dae4d5152e0814bf17fc7eee75045d3469f5ca8", size = 7551, upload-time = "2026-04-08T05:20:24.209Z" }, +] + +[[package]] +name = "pydantic-ai-slim" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/84/4cf98c41a2019a5c5ed6aa5d5fa2bbc8b70b152b527c56d27dabbeaeb75c/pydantic_ai_slim-1.78.0.tar.gz", hash = "sha256:97c6467a6bb09f61fd48cd828db066204ae77419d14a4edf47f90f72d06ab11f", size = 531385, upload-time = "2026-04-08T05:20:36.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/91/487992a441c03f16525885ee541083f66f579500edd7aa8b838f3296455f/pydantic_ai_slim-1.78.0-py3-none-any.whl", hash = "sha256:36f88bab6016186b958363ca1254741999df276322d7066dd69793dcb2134b66", size = 680002, upload-time = "2026-04-08T05:20:27.508Z" }, +] + +[package.optional-dependencies] +ag-ui = [ + { name = "ag-ui-protocol" }, + { name = "starlette" }, +] +anthropic = [ + { name = "anthropic" }, +] +bedrock = [ + { name = "boto3" }, +] +cli = [ + { name = "argcomplete" }, + { name = "prompt-toolkit" }, + { name = "pyperclip" }, + { name = "pyyaml" }, + { name = "rich" }, +] +cohere = [ + { name = "cohere", marker = "sys_platform != 'emscripten' or (extra == 'extra-10-imas-codex-cpu' and extra == 'extra-10-imas-codex-gpu') or (extra == 'extra-10-imas-codex-gpu' and extra == 'extra-10-imas-codex-test')" }, +] +evals = [ + { name = "pydantic-evals" }, +] +fastmcp = [ + { name = "fastmcp" }, +] +google = [ + { name = "google-genai" }, +] +groq = [ + { name = "groq" }, +] +huggingface = [ + { name = "huggingface-hub" }, +] +logfire = [ + { name = "logfire", extra = ["httpx"] }, +] +mcp = [ + { name = "mcp" }, +] +mistral = [ + { name = "mistralai" }, +] +openai = [ + { name = "openai" }, + { name = "tiktoken" }, +] +retries = [ + { name = "tenacity" }, +] +spec = [ + { name = "pydantic-handlebars" }, + { name = "pyyaml" }, +] +temporal = [ + { name = "temporalio" }, +] +ui = [ + { name = "starlette" }, +] +vertexai = [ + { name = "google-auth" }, + { name = "requests" }, +] +xai = [ + { name = "xai-sdk" }, +] + [[package]] name = "pydantic-core" version = "2.41.5" @@ -2495,6 +3164,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "pydantic-evals" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "pydantic-ai-slim" }, + { name = "pyyaml" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/80/539238f284f6a4fdef5737a0cb7efa746e4259bb27526af271898784f2fa/pydantic_evals-1.78.0.tar.gz", hash = "sha256:8608068c2569a0169977526a93ddea45e924331115233eb1f297ab19653e14e0", size = 65818, upload-time = "2026-04-08T05:20:37.844Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/4c/e6bcf445ff3f15211c217975a11d401e78b3f2d1e989c50d859527e81050/pydantic_evals-1.78.0-py3-none-any.whl", hash = "sha256:b00cc22e2d24a0771f40fc7e3b2b4afeec7c99bda8cfce37e6bee58e7a29fe3c", size = 77739, upload-time = "2026-04-08T05:20:29.577Z" }, +] + +[[package]] +name = "pydantic-graph" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/e4/eb52021f43f2ac495955af19219c1ab261d707bbd15f4dc229079c2276d4/pydantic_graph-1.78.0.tar.gz", hash = "sha256:dd627e37cb3adaf8c95cca6a4b33e0d1b7fc9bed075dc3b8ad5df2c2a3cb432b", size = 58682, upload-time = "2026-04-08T05:20:39.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/97/e3dd1a1c6f6b9c104c844c1a2383361a831d1b027c99c0ac1a20544f0b13/pydantic_graph-1.78.0-py3-none-any.whl", hash = "sha256:0302835f46da3ee70ba3602a4d886c41a76fa8750f23f4257b968163ba4bb89f", size = 72500, upload-time = "2026-04-08T05:20:31.237Z" }, +] + +[[package]] +name = "pydantic-handlebars" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/16/d41768bd3fd77e6250c20be11a3e68fee5fff07c3356455e6708f6a60f2a/pydantic_handlebars-0.1.0.tar.gz", hash = "sha256:1931c54946add1b5e3796c9bf6a005ed7662cef0109bb05c352f0b3d031a1260", size = 159826, upload-time = "2026-03-01T20:00:17.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5f/86b1630be61bdebf253c2f953a6c3f073ec21bb0725565ea3896802e1ca3/pydantic_handlebars-0.1.0-py3-none-any.whl", hash = "sha256:8a436fe8bc607295eb04bec58bd6e2c9498c9e069c557ff0b505e3d568c783bc", size = 40890, upload-time = "2026-03-01T20:00:16.106Z" }, +] + [[package]] name = "pydantic-settings" version = "2.13.1" @@ -3042,7 +3755,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -3050,9 +3763,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] @@ -3168,6 +3881,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + [[package]] name = "safetensors" version = "0.7.0" @@ -3274,6 +3999,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "shexjsg" version = "0.8.2" @@ -3504,6 +4238,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "strictyaml" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/08/efd28d49162ce89c2ad61a88bd80e11fb77bc9f6c145402589112d38f8af/strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407", size = 115206, upload-time = "2023-03-10T12:50:27.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size = 123917, upload-time = "2023-03-10T12:50:17.242Z" }, +] + [[package]] name = "sympy" version = "1.13.1" @@ -3541,6 +4287,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, ] +[[package]] +name = "temporalio" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nexus-rpc" }, + { name = "protobuf" }, + { name = "types-protobuf" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/9c/3782bab0bf11a40b550147c19a5d1a476c17405391751982408902d9f138/temporalio-1.25.0.tar.gz", hash = "sha256:a3bbec1dcc904f674402cfa4faae480fda490b1c53ea5440c1f1996c562016fb", size = 2152534, upload-time = "2026-04-08T18:53:55.388Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/e3/5676dd10d1164b6d6ca8752314054097b89c5da931e936af402a7b15236c/temporalio-1.25.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6dc1bc8e1773b1a833d86a7ede2dd90ef4e031ced5b748b59e7f09a5bf9b327d", size = 13943906, upload-time = "2026-04-08T18:53:30.022Z" }, + { url = "https://files.pythonhosted.org/packages/89/50/7cbf7f845973be986ec165348f72f7a409750842a04d554965a39be5cb4f/temporalio-1.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3c8fdcf79ea5ae8ae2cf6f48072e4a86c3e0f4778f6a8a066c6ff1d336587db4", size = 13298719, upload-time = "2026-04-08T18:53:35.95Z" }, + { url = "https://files.pythonhosted.org/packages/d2/31/d474bab8535552add6ed289911bf1ffae5d7071823ece1069842190fcaed/temporalio-1.25.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:141f37aaafd7d090ba5c8776e4e9bc60df1fbc64b9f50c8f00e905a436588ddc", size = 13555435, upload-time = "2026-04-08T18:53:41.36Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c8/e7dc053d6107bf2a037a3c9fe7b86639a25dcb888bde0e1ca366901ee47f/temporalio-1.25.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7ca5bb80264976477d4dc7a839b3d22af8577ae92306526a061481db49bf92", size = 14052050, upload-time = "2026-04-08T18:53:46.44Z" }, + { url = "https://files.pythonhosted.org/packages/08/70/9340ed3a578321cbc153041d34834bb1ec3f1f3e3d9cded47cd1b7c3e403/temporalio-1.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9411534279a2e64847231b6059c214bff4d57cfd1532bd09f333d0b1603daa7f", size = 14299684, upload-time = "2026-04-08T18:53:52.482Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "textual" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify", "plugins"] }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/30/38b615f7d4b16f6fdd73e4dcd8913e2d880bbb655e68a076e3d91181a7ee/textual-6.2.1.tar.gz", hash = "sha256:4699d8dfae43503b9c417bd2a6fb0da1c89e323fe91c4baa012f9298acaa83e1", size = 1570645, upload-time = "2025-10-01T16:11:24.467Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/93/02c7adec57a594af28388d85da9972703a4af94ae1399542555cd9581952/textual-6.2.1-py3-none-any.whl", hash = "sha256:3c7190633cd4d8bfe6049ae66808b98da91ded2edb85cef54e82bf77b03d2a54", size = 710702, upload-time = "2025-10-01T16:11:22.161Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -3723,23 +4513,22 @@ wheels = [ [[package]] name = "transformers" -version = "4.57.6" +version = "5.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock" }, { name = "huggingface-hub" }, { name = "numpy" }, { name = "packaging" }, { name = "pyyaml" }, { name = "regex" }, - { name = "requests" }, { name = "safetensors" }, { name = "tokenizers" }, { name = "tqdm" }, + { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/35/67252acc1b929dc88b6602e8c4a982e64f31e733b804c14bc24b47da35e6/transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3", size = 10134912, upload-time = "2026-01-16T10:38:39.284Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/9d/fb46e729b461985f41a5740167688b924a4019141e5c164bea77548d3d9e/transformers-5.5.0.tar.gz", hash = "sha256:c8db656cf51c600cd8c75f06b20ef85c72e8b8ff9abc880c5d3e8bc70e0ddcbd", size = 8237745, upload-time = "2026-04-02T16:13:08.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550", size = 11993498, upload-time = "2026-01-16T10:38:31.289Z" }, + { url = "https://files.pythonhosted.org/packages/e7/28/35f7411ff80a3640c1f4fc907dcbb6a65061ebb82f66950e38bfc9f7f740/transformers-5.5.0-py3-none-any.whl", hash = "sha256:821a9ff0961abbb29eb1eb686d78df1c85929fdf213a3fe49dc6bd94f9efa944", size = 10245591, upload-time = "2026-04-02T16:13:03.462Z" }, ] [[package]] @@ -3848,6 +4637,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/eb/65f5ba83c2a123f6498a3097746607e5b2f16add29e36765305e4ac7fdd8/triton-3.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8182f42fd8080a7d39d666814fa36c5e30cc00ea7eeeb1a2983dbb4c99a0fdc", size = 209551444, upload-time = "2024-10-14T16:05:53.433Z" }, ] +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "types-protobuf" +version = "6.32.1.20260221" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -3878,6 +4703,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "uncalled-for" version = "0.3.1" @@ -4029,20 +4863,40 @@ wheels = [ [[package]] name = "wrapt" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/37/ae31f40bec90de2f88d9597d0b5281e23ffe85b893a47ca5d9c05c63a4f6/wrapt-2.1.1.tar.gz", hash = "sha256:5fdcb09bf6db023d88f312bd0767594b414655d58090fc1c46b3414415f67fac", size = 81329, upload-time = "2026-02-03T02:12:13.786Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/cb/4d5255d19bbd12be7f8ee2c1fb4269dddec9cef777ef17174d357468efaa/wrapt-2.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab8e3793b239db021a18782a5823fcdea63b9fe75d0e340957f5828ef55fcc02", size = 61143, upload-time = "2026-02-03T02:11:46.313Z" }, - { url = "https://files.pythonhosted.org/packages/6f/07/7ed02daa35542023464e3c8b7cb937fa61f6c61c0361ecf8f5fecf8ad8da/wrapt-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c0300007836373d1c2df105b40777986accb738053a92fe09b615a7a4547e9f", size = 61740, upload-time = "2026-02-03T02:12:51.966Z" }, - { url = "https://files.pythonhosted.org/packages/c4/60/a237a4e4a36f6d966061ccc9b017627d448161b19e0a3ab80a7c7c97f859/wrapt-2.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2b27c070fd1132ab23957bcd4ee3ba707a91e653a9268dc1afbd39b77b2799f7", size = 121327, upload-time = "2026-02-03T02:11:06.796Z" }, - { url = "https://files.pythonhosted.org/packages/ae/fe/9139058a3daa8818fc67e6460a2340e8bbcf3aef8b15d0301338bbe181ca/wrapt-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b0e36d845e8b6f50949b6b65fc6cd279f47a1944582ed4ec8258cd136d89a64", size = 122903, upload-time = "2026-02-03T02:12:48.657Z" }, - { url = "https://files.pythonhosted.org/packages/91/10/b8479202b4164649675846a531763531f0a6608339558b5a0a718fc49a8d/wrapt-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aeea04a9889370fcfb1ef828c4cc583f36a875061505cd6cd9ba24d8b43cc36", size = 121333, upload-time = "2026-02-03T02:11:32.148Z" }, - { url = "https://files.pythonhosted.org/packages/5f/75/75fc793b791d79444aca2c03ccde64e8b99eda321b003f267d570b7b0985/wrapt-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d88b46bb0dce9f74b6817bc1758ff2125e1ca9e1377d62ea35b6896142ab6825", size = 120458, upload-time = "2026-02-03T02:11:16.039Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8f/c3f30d511082ca6d947c405f9d8f6c8eaf83cfde527c439ec2c9a30eb5ea/wrapt-2.1.1-cp312-cp312-win32.whl", hash = "sha256:63decff76ca685b5c557082dfbea865f3f5f6d45766a89bff8dc61d336348833", size = 58086, upload-time = "2026-02-03T02:12:35.041Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c8/37625b643eea2849f10c3b90f69c7462faa4134448d4443234adaf122ae5/wrapt-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b828235d26c1e35aca4107039802ae4b1411be0fe0367dd5b7e4d90e562fcbcd", size = 60328, upload-time = "2026-02-03T02:12:45.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/79/56242f07572d5682ba8065a9d4d9c2218313f576e3c3471873c2a5355ffd/wrapt-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:75128507413a9f1bcbe2db88fd18fbdbf80f264b82fa33a6996cdeaf01c52352", size = 58722, upload-time = "2026-02-03T02:12:27.949Z" }, - { url = "https://files.pythonhosted.org/packages/c4/da/5a086bf4c22a41995312db104ec2ffeee2cf6accca9faaee5315c790377d/wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7", size = 43886, upload-time = "2026-02-03T02:11:45.048Z" }, +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "xai-sdk" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/32/bb8385f7a3b05ce406b689aa000c9a34289caa1526f1c093a1cefc0d9695/xai_sdk-1.11.0.tar.gz", hash = "sha256:ca87a830d310fb8e06fba44fb2a8c5cdf0d9f716b61126eddd51b7f416a63932", size = 404313, upload-time = "2026-03-27T18:23:10.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/76/86d9a3589c725ce825d2ed3e7cb3ecf7f956d3fd015353d52197bb341bcd/xai_sdk-1.11.0-py3-none-any.whl", hash = "sha256:fe58ce6d8f8115ae8bd57ded57bcd847d0bb7cb28bb7b236abefd4626df1ed8d", size = 251388, upload-time = "2026-03-27T18:23:08.573Z" }, ] [[package]]