diff --git a/docs/plans/grammar-release-and-downstream-pin.html b/docs/plans/grammar-release-and-downstream-pin.html new file mode 100644 index 00000000..0e63cac9 --- /dev/null +++ b/docs/plans/grammar-release-and-downstream-pin.html @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + Grammar Release And Downstream Pin | imas-standard-names + + + + + + +
+

§1 — Why this release exists

+

A downstream catalog consumer resolves this package as an editable + path install rather than from a released version. That arrangement was + convenient while the grammar was moving and is now actively harmful: the + consumer's own dependency declaration pins 0.8.9, its environment + resolves a development tree, and the version string it reports is frozen + build-time metadata read from the installed distribution. So the consumer + records a version that does not describe the code it ran, and any release it + cuts would be built against a tree nobody declared.

+

Thirteen commits on this repository's main carry the work the + consumer needs. Unary operators now render adjacent to their base group when an + of, at, or due_to tail is present, with + parser symmetry and recursive placement through composite operand trees. An + indexed operator renders its operand before its coordinate index. Twenty-five + bare vocabulary tokens the closed vocabulary was missing are defined, six more + are added from data-dictionary evidence, and the beta inclination + qualifier is withdrawn because every data-dictionary leaf named exactly + beta is the oblique-element inclination angle and the token invited a + collision with plasma beta.

+

The grammar suite passes with zero failures at both ends — 1148 tests before + and 1170 after, 22 added — and a census over the consumer's live catalog recorded + 149 renderer changes across 2974 names with zero reparse failures and zero + intermediate-representation mismatches.

+

The chain is strictly ordered, and each link is blocked by the one before it:

+
+ + + + CI green + blocked · §2 + + + + + + merge upstream + pull request 31 + + + + + + release tag + §3 + + + + + + consumer pin + §3 + + +
The release chain. Every downstream step waits on the contract + test in §2, which is the only failing check.
+
+ +

§2 — A contract test pins the entire vocabulary

+

The upstream pull request is mergeable and its fast build passes, but + build (3.12), build (3.13) and coverage + all fail on a single test: + tests/test_physical_base_kinds.py::test_only_momentum_and_torque_bases_change_to_vector.

+

The failure is a digest mismatch. _kind_map_digest hashes every + name=kind pair in the physical-base vocabulary except three named + vector corrections, and the expected digest is frozen as a literal in the test + module. Adding any new base therefore changes the digest, even when no + existing base has changed kind. Thirty-one tokens were deliberately added, so + the digest moved for exactly the reason the vocabulary work intended.

+

Reading the test exposes a second problem worth fixing in the same pass. Its + three assertions do not carry equal weight. The first — that the three named + corrections are vector — is real. The third derives its own baseline + from the current kind map by overriding those same three entries to + scalar, then asserts that precisely those three differ; given the + first assertion that is true by construction and can never fail, so it is + vacuous. That leaves the frozen digest as the only assertion pinning anything, + and it pins far more than the test's name claims: the whole vocabulary rather + than the kinds of the bases that existed when the baseline was taken.

+

The repair is to assert what the test says it asserts. Pin the kind of each + base present in the recorded baseline by name, so that altering an + existing base's kind fails while adding a new base does not. A digest over a + growing map is a moving value frozen in a contract, and it will break on every + legitimate vocabulary addition until it is replaced.

+ +

§3 — The tag, and moving the consumer off its override

+

Releases here are tag-driven through the standard-names release + command, which refuses unless the working tree is clean, the branch is + main, the local branch is synced with upstream, and continuous + integration has passed for HEAD. Those preconditions are the reason + §2 blocks everything downstream rather than merely being untidy. Tagging pushes + to upstream and triggers the publish workflow.

+

Once a version is published, the consumer drops the editable source override + from its dependency declaration and pins the released version in both places it + names this package. That single change also repairs the consumer's frozen + version stamp, because the reported version and the resolved code become the same + fact again rather than two facts that agreed once at install time.

+

The consumer's change cannot be verified from an isolated worktree: a + pin-changing worktree resolves the main checkout's dependency declaration through + a shared environment, so any concurrent command in that environment reverts the + install mid-measurement. It must be sequenced when the consumer's fleet is at + rest.

+ +
+

§ Decisions

+
+

Does this publish as a release candidate first, or straight to a stable patch?

+

+ + +

+

+
+
+

How should the physical-base kind contract be pinned?

+

+ + +

+

Refreshing the digest restores the suite and leaves the + defect in place, so the next vocabulary addition breaks it again. Pinning by + name distinguishes an added base from a changed kind, which is what the test + claims to measure.

+
+
+ + +
+
+

THE CONTRACT NOW MEASURES WHAT ITS NAME CLAIMS. A frozen SHA-256 digest over the entire physical-base vocabulary is replaced by an explicit 177-entry baseline mapping measured from the loader, projected onto only those named keys - so an existing base changing kind fails while an added base passes. 1 failed at base against 3 passed after, and the whole grammar suite is green at 2110 passed, 34 skipped, 82 xfailed, exit 0. THE WORKER DISPUTED MY SPEC AND WAS RIGHT, which is the most valuable thing here. I told it to also assert that no base outside the three vector corrections carries kind vector, reading the test name as only these are vector. The name says only these CHANGE TO vector, and the loader holds 16 established vector bases outside the corrections, so my assertion was false of this vocabulary and it stopped rather than writing something that could not pass. It also confirmed my reading of the third assertion with a proper argument rather than by agreement: the old baseline copied every current value and overrode only the corrections to scalar, so once the first assertion held, exactly those three necessarily differed and no other key could - no independent failure case existed. TWO-DIRECTION PROOF, both concrete: flipping loaded acceleration from vector to scalar must raise, and inserting a synthetic base must still pass. The census is the part a reader gains most from - 177 bases, 19 vectors, 16 of them established outside the corrections, zero missing current keys and zero stale baseline keys - because the frozen digest had hidden every one of those facts behind one opaque hash. RECEIPT, and the first codex run today to take two turns: 15m50s of a 40m fence, 65 tool calls, 2 turns, zero provider refusals, three self-corrected local retries (an unsupported patch operation, a malformed poll, an over-escaped f-string). Its named discovery was measuring and transcribing the complete base map, which is exactly the work a digest exists to avoid and exactly why the digest was the wrong instrument

+
+
+
+ + diff --git a/docs/state/imas-standard-names/crew.json b/docs/state/imas-standard-names/crew.json new file mode 100644 index 00000000..cbb0f968 --- /dev/null +++ b/docs/state/imas-standard-names/crew.json @@ -0,0 +1,162 @@ +{ + "updated": "2026-09-06T12:48:18", + "project": "imas-standard-names", + "doc": "crew", + "data": { + "members": [ + { + "id": "cx-kindpin", + "harness": "codex", + "role": "implement", + "session_id": "01a07642-fbe2-7d02-97de-ed56606becbb", + "session_model": "gpt-5.6-sol", + "sessions": { + "{\"alias\":\"sol5.6\",\"backend\":\"codex\",\"effort\":\"high\",\"launch\":\"cli\",\"model\":\"gpt-5.6-sol\",\"sandbox\":\"worktree-full\"}": "01a07642-fbe2-7d02-97de-ed56606becbb" + }, + "created": "2026-09-06" + } + ], + "runs": [ + { + "run_id": "r-20260906T102805767185-n-grdp-the-kind-pin-tolerates-new-bases", + "plan": "grammar-release-and-downstream-pin", + "section": "\u00a72", + "node": "n-grdp-the-kind-pin-tolerates-new-bases", + "node_definition": { + "coordinator": { + "authoring_turn": { + "source": "claude-code-session-transcript", + "status": "measured", + "tokens": { + "cache_creation_input_tokens": 338, + "cached_input_tokens": 489335, + "input_tokens": 489675, + "output_tokens": 2066, + "reasoning_output_tokens": 0, + "total_tokens": 491741, + "uncached_input_tokens": 2 + } + }, + "harness": "claude-code", + "runtime_session_id": "11b50f59-c77b-4356-8bb3-6dafc6d0a4c0", + "session_id": "ship-s10-20260905" + }, + "done_when": "ONE TEST BLOCKS AN ENTIRE RELEASE CHAIN. Read tests/test_physical_base_kinds.py in full - it is under 40 lines - and nothing else beyond the vocabulary loader it calls.\n\nTHE FAILURE, from continuous integration on the open upstream pull request. build (3.12), build (3.13) and coverage all fail on test_only_momentum_and_torque_bases_change_to_vector at tests/test_physical_base_kinds.py:29, with a digest mismatch: expected 6062e5c22be7788220b390f09a5c8c1f1037ece0f8bc4422b2e6a3e3b1763956, observed 7df8c3f2f3a6ed41ce28fdbb49b0bb4045b8717966a4999244433afaf579e3c4. The fast build job passes; only the test jobs fail.\n\nWHY IT MOVED, and it is not a regression. _kind_map_digest hashes every name=kind pair returned by load_physical_bases().bases except the three names in _VECTOR_CORRECTIONS, and _BASELINE_UNCHANGED_KIND_DIGEST is frozen as a module literal. Thirty-one vocabulary tokens were deliberately added on this branch - twenty-five bare tokens and six data-dictionary-backed ones - and the beta inclination qualifier was withdrawn. So the digest moved because the vocabulary grew, not because any existing base changed kind.\n\nTHE DECISION IS ALREADY LOCKED in the plan, decision key kind-pin-shape: pin each baseline base's kind BY NAME, and do not simply refresh the frozen digest. Refreshing restores the suite and leaves the defect, so the next vocabulary addition breaks it again.\n\nREAD THE THIRD ASSERTION BEFORE YOU CHANGE ANYTHING, because it changes what you must preserve. The test makes three claims. The first, that the three named corrections carry kind vector, is real. The third builds baseline_kinds from the CURRENT kind map by overriding those same three entries to scalar, then asserts exactly those three differ - which, given the first assertion, is true by construction and can never fail. Verify that reading yourself and state your conclusion. If you agree it is vacuous, replace it rather than preserving it; if you find a case where it can fail, say what that case is and keep it. Do not delete an assertion you have not shown to be empty.\n\nTHE DELIVERABLE: an assertion that fails when an existing base's kind changes and passes when a new base is added. Record the baseline as an explicit name-to-kind mapping for the bases that exist now, compare only those keys, and let unlisted names through. Keep the first assertion exactly as it is. The test's name promises that only momentum and torque bases became vector, so also assert that NO base outside _VECTOR_CORRECTIONS carries kind vector - that is the claim the name makes and the frozen digest only approximated.\n\nPROVE BOTH DIRECTIONS, and this is the measure. Show the suite passing at the current vocabulary. Then show the new assertion FAILING when an existing base's kind is altered - do that by mutating the loaded mapping inside a test, never by editing a vocabulary file - and show it PASSING when a synthetic new base is added to the loaded mapping. A gate that only demonstrates the green case has not been shown to catch anything.\n\nRun once before your change and once after:\nuv run pytest tests/test_physical_base_kinds.py -p no:cacheprovider\nThen run the whole grammar suite once after your change as a blast-radius check and report its counts: uv run pytest tests/ -p no:cacheprovider\nReport the before and after counts for both. The baseline mapping must be MEASURED from the loader, never transcribed from anything I have told you.\n\nCONSTRAINTS. Change no vocabulary file, no parser, no renderer - this node touches the test module only. Do not edit the digest helper into something that still hashes a growing map. Do not add a date, a version, a plan name or a pull-request number anywhere in the module. This repository's environment is its own, so plain uv run is correct here.\n\nOPERATIONAL RECEIPT REQUIRED IN YOUR MANIFEST: your wall-clock from first tool call to manifest write, your number of tool calls, how many turns you used, whether any request was retried or refused and why, and one sentence naming what you had to discover versus what this brief handed you, including anything under-specified.", + "estimated_hours": null, + "goal": "The physical-base kind contract catches a changed kind while tolerating an added base", + "id": "n-grdp-the-kind-pin-tolerates-new-bases", + "manifest_path": "/home/ITER/mcintos/.config/reckon/crew/runs/r-20260906T102805767185-n-grdp-the-kind-pin-tolerates-new-bases/manifest.md", + "plan": "grammar-release-and-downstream-pin", + "requires_decisions": [], + "role": "implement", + "section": "\u00a72", + "spec_level": "exact", + "time_budget": "40m", + "write_paths": [ + "tests/test_physical_base_kinds.py" + ] + }, + "role": "implement", + "spec_level": "exact", + "member": "cx-kindpin", + "backend": "codex", + "local": false, + "agent": { + "alias": "sol5.6", + "backend": "codex", + "effort": "high", + "launch": "cli", + "model": "gpt-5.6-sol", + "sandbox": "worktree-full" + }, + "dispatched_at": "2026-09-06T10:28:15Z", + "completed_at": "2026-09-06T10:46:05Z", + "completed_at_source": "stream_mtime", + "worker_seconds": 1070, + "worker_seconds_source": "wall_fallback", + "wall_seconds": 1070, + "stalled": false, + "time_budget": "40m", + "base_sha": "c632a5316ac80aed01f1503dbf519f090adf1f32", + "commits": [ + "81cc5c2761f2a5c253697a8486f3d179580a3045" + ], + "changed_lines": { + "added": 205, + "removed": 19, + "files": 1 + }, + "tests_added": 2, + "gate": "passed", + "gate_check": { + "command": "uv run pytest tests/test_physical_base_kinds.py -p no:cacheprovider", + "exit_status": 0, + "log_path": "/home/ITER/mcintos/.config/reckon/crew/runs/r-20260906T102805767185-n-grdp-the-kind-pin-tolerates-new-bases/after-physical-base-kinds.log", + "log_digest": null + }, + "suite_delta": null, + "failure_classification": null, + "outcome": "", + "manifest_path": "/home/ITER/mcintos/.config/reckon/crew/runs/r-20260906T102805767185-n-grdp-the-kind-pin-tolerates-new-bases/manifest.md", + "scope_changed": false, + "session_id": "01a07642-fbe2-7d02-97de-ed56606becbb", + "budget": { + "headroom": "unknown", + "utilisation_pct": null, + "rate_limit_type": null, + "rate_limit_period_minutes": null, + "resets_at": null, + "threshold_status": null, + "surpassed_threshold": null, + "tokens": { + "cache_write_input_tokens": 0, + "cache_write_input_tokens_cumulative": 0, + "cached_input_tokens": 5009920, + "cached_input_tokens_cumulative": 5009920, + "input_tokens": 5065395, + "input_tokens_cumulative": 5065395, + "output_tokens": 21664, + "output_tokens_cumulative": 21664, + "reasoning_output_tokens": 6410, + "reasoning_output_tokens_cumulative": 6410 + }, + "cost_usd": null, + "refusal": false, + "detail": "backend reports token usage but no headroom" + }, + "lineage": null, + "shadow_controlled": null, + "shadow_patch": "", + "unreconciled_override": { + "grace": "5m", + "requested": true, + "waived_runs": [] + }, + "throughput": { + "detail": "turn tokens recorded, but no span was supplied to rate them", + "generated_tokens": 28074, + "peak_input_tokens": 10075315 + }, + "attempt": 2, + "attempt_kind": "resume", + "worktree_retention": { + "classification": "retained-for-resume", + "worktree": "/home/ITER/mcintos/Code/.reckon-worktrees/imas-standard-names-5be88ee0fdc3/ship-s10-20260905/n-grdp-the-kind-pin-tolerates-new-bases", + "session_id": "01a07642-fbe2-7d02-97de-ed56606becbb", + "session_source": "pointer", + "retained_at": "2026-09-06T10:46:05Z" + }, + "execution_fit": { + "allowed": true, + "execution_capable": true, + "matched_measure": "uv run pytest tests/test_physical_base_kinds.py", + "override": false, + "role": "implement", + "status": "compatible" + } + } + ], + "holds": [], + "_version": 3 + } +} diff --git a/imas_standard_names/grammar/model_types.py b/imas_standard_names/grammar/model_types.py index 0a702ad6..4da0bdd6 100644 --- a/imas_standard_names/grammar/model_types.py +++ b/imas_standard_names/grammar/model_types.py @@ -30,6 +30,10 @@ class Component(StrEnum): NORMALIZED_VERTICAL = "normalized_vertical" NORMALIZED_TOROIDAL = "normalized_toroidal" NORMALIZED_POLOIDAL = "normalized_poloidal" + S0 = "s0" + S1 = "s1" + S2 = "s2" + S3 = "s3" class Coordinate(StrEnum): diff --git a/imas_standard_names/grammar/parser.py b/imas_standard_names/grammar/parser.py index a77ac3c4..7044b753 100644 --- a/imas_standard_names/grammar/parser.py +++ b/imas_standard_names/grammar/parser.py @@ -14,18 +14,22 @@ Algorithm:: - 1. Strip trailing _due_to_ -> mechanism - 2. Strip trailing _of_/_at_/_over_/_along_ -> locus + 1. Normalize operand-first indexed operators + 2. Preserve any tail-final postfix operator for canonical diagnosis + 3. Strip trailing _due_to_ -> mechanism + 4. Resolve a leading binary expression before locus stripping + 5. Strip trailing _of_/_at_/_over_/_along_ -> locus (longest registry-backed match; only _at_ may fall back with a vocab_gap diagnostic — _over_ and _along_ require a registered locus) - 3. Peel outer operators right-to-outermost -> operators + 6. Peel unary operators immediately before an of/at/due_to tail + 7. Peel outer operators right-to-outermost -> operators a) unary_postfix (longest match at end) b) unary_prefix (longest match `_of_...`) c) bare prefix over a nested operator expression d) binary (`_of___`) repeat until no operator peels - 4. Match residue: carrier > base > axis+resolve > qualifier+recurse + 8. Match residue: carrier > base > axis+resolve > qualifier+recurse Projection is detected inline when an axis prefix precedes a resolvable base (COMPONENT) or carrier (COORDINATE). Short form only — ``_component_of_`` and ``_coordinate_of_`` @@ -36,13 +40,12 @@ edit-distance suggestions. No legacy open-fallback behaviour is retained. """ -from __future__ import annotations - import re from collections.abc import Mapping from contextvars import ContextVar from dataclasses import dataclass, field from difflib import get_close_matches +from functools import cache from typing import Any from imas_standard_names.grammar import vocab_loaders @@ -394,7 +397,7 @@ class _CachedParseError: canonical_form: str | None = None @classmethod - def from_exception(cls, error: ParseError) -> _CachedParseError: + def from_exception(cls, error: ParseError) -> "_CachedParseError": return cls( str(error), tuple(error.suggestions), @@ -883,6 +886,67 @@ def _peel_trailing_postfix_operator( ) +def _peel_repositioned_tail_operator( + s: str, v: Vocabularies +) -> tuple[OperatorApplication | Qualifier | None, str]: + """Peel one unary operator placed between its base and trailing locus.""" + + if _resolves_without_postfix(s, v): + return None, s + + prefix_operators = { + name + for name, meta in v.operators.items() + if meta.get("kind") == OperatorKind.UNARY_PREFIX.value + } + if _spells_leading_operator_application( + s, prefix_operators, _binary_operator_tokens(v), v + ): + return None, s + + unary_operators = { + name: meta + for name, meta in v.operators.items() + if meta.get("kind") + in {OperatorKind.UNARY_PREFIX.value, OperatorKind.UNARY_POSTFIX.value} + } + unary_tokens = set(unary_operators) + binary_operators = _binary_operator_tokens(v) + + @cache + def resolves_operand(candidate: str) -> bool: + if _resolves_without_postfix(candidate, v): + return True + if _spells_leading_operator_application( + candidate, prefix_operators, binary_operators, v + ): + return True + return any( + resolves_operand(candidate[: -len(token) - 1]) + for token in unary_tokens + if candidate.endswith(f"_{token}") and len(candidate) > len(token) + 1 + ) + + match = None + new_s = s + for token in sorted(unary_tokens, key=len, reverse=True): + marker = f"_{token}" + if not s.endswith(marker) or len(s) <= len(marker): + continue + candidate = s[: -len(marker)] + if resolves_operand(candidate): + match = token + new_s = candidate + break + if match is None: + return None, s + + kind = OperatorKind(unary_operators[match]["kind"]) + if kind is OperatorKind.UNARY_PREFIX and match in BARE_PREFIX_OPERATORS: + return Qualifier(token=match), new_s + return OperatorApplication(kind=kind, op=match), new_s + + def _resolves_without_postfix(s: str, v: Vocabularies) -> bool: """Whether the complete spelling resolves without a postfix peel.""" try: @@ -924,6 +988,52 @@ def _coordinate_universe(v: Vocabularies) -> frozenset[str]: return v.carriers | frozenset(v.carrier_aliases) | frozenset(v.axes) +def _rewrite_operand_first_indexed_operator(s: str, v: Vocabularies) -> str: + """Rewrite an operand-first coordinate derivative to the internal form. + + The lossless IR keeps a coordinate-indexed operator as one fused token. + Canonical surface spelling places the complete operand before that index, + so parsing temporarily restores the fused prefix spelling consumed by the + existing operator peel. The renderer converts the fused token back to the + canonical operand-first spelling. + """ + candidates: list[tuple[str, str, str]] = [] + for op, meta in v.operators.items(): + fixed_operator, fixed_relation, fixed_index = op.partition("_with_respect_to_") + if fixed_relation and fixed_operator and fixed_index: + candidates.append((op, fixed_operator, fixed_index)) + if ( + meta.get("kind") != OperatorKind.UNARY_PREFIX.value + or not meta.get("indexed") + or list(meta.get("index_params") or []) != ["coord"] + ): + continue + operator, relation, _ = op.partition("_with_respect_to") + if not relation or not operator: + continue + for coord in _coordinate_universe(v): + canonical_coord = v.carrier_aliases.get(coord, coord) + candidates.append((f"{op}_{canonical_coord}", operator, coord)) + + for fused_op, operator, index in sorted( + candidates, key=lambda candidate: len(candidate[2]), reverse=True + ): + suffix = f"_with_respect_to_{index}" + if not s.endswith(suffix): + continue + operand_end = len(s) - len(suffix) + marker = f"{operator}_of_" + operator_start = s.rfind(marker, 0, operand_end) + if operator_start < 0: + continue + operand_start = operator_start + len(marker) + operand = s[operand_start:operand_end] + if not operand: + continue + return f"{s[:operator_start]}{fused_op}_of_{operand}" + return s + + def _longest_indexed_prefix_operator_match( s: str, prefix_ops: set[str], v: Vocabularies ) -> tuple[str, int] | None: @@ -1896,19 +2006,13 @@ def _parse_uncached( """Parse one memoization-cache miss.""" v = vocabs diagnostics: list[Diagnostic] = [] - s = name + s = _rewrite_operand_first_indexed_operator(name, v) - # Trailing-postfix pass. - # - # A postfix decomposition operator (``magnitude``, ``moment``, ...) renders - # at the very END of the canonical string — AFTER any locus/mechanism - # suffix (``compose`` wraps ``base + locus + mechanism`` as - # ``{core}_{op}``). Peel these BEFORE the mechanism/locus strips so a - # postfix token sitting after a locus/mechanism is not greedily absorbed - # into the process/locus token (which would silently drop the operator, - # e.g. ``velocity_due_to_pellet_injection_magnitude`` fabricating a - # ``pellet_injection_magnitude`` process). These ops are the OUTERMOST - # trailing wrap, so they go to the FRONT of the operator stack. + # Tail-final postfix acceptance pass. Peeling a postfix token before the + # mechanism/locus strips prevents those strips from absorbing it into a + # fabricated process or locus token. Canonical composition moves this + # operator before the tail; lenient parsing retains the authored IR so the + # strict canonical check can report the unique rendering. trailing_postfix: list[OperatorApplication] = [] while True: op_app, new_s = _peel_trailing_postfix_operator(s, v) @@ -1945,6 +2049,27 @@ def _parse_uncached( locus, s, locus_diags = _strip_locus(s, v) diagnostics.extend(locus_diags) + # A unary operator on an ordinary base moves immediately before an + # ``of``/``at`` locus or ``due_to`` mechanism. Peel that suffix before + # resolving the base so the parser reconstructs the same outer-to-inner + # operator stack that the renderer consumed. Bare prefix operators retain + # their qualifier-held IR representation. + has_repositioning_tail = mechanism is not None or ( + locus is not None and locus.relation in {LocusRelation.OF, LocusRelation.AT} + ) + repositioned_operators: list[OperatorApplication] = [] + repositioned_qualifiers: list[Qualifier] = [] + if has_repositioning_tail: + while True: + operator, new_s = _peel_repositioned_tail_operator(s, v) + if operator is None: + break + if isinstance(operator, Qualifier): + repositioned_qualifiers.append(operator) + else: + repositioned_operators.append(operator) + s = new_s + # Operator-peeling pass (outermost layer after locus/mechanism). # Trailing postfix operators peeled first are the outermost wrap and # precede anything peeled here (a prefix/binary operator-of form). @@ -1961,6 +2086,8 @@ def _parse_uncached( operator_stack.append(op_app) s = new_s + operator_stack.extend(repositioned_operators) + if binary_terminator is not None: # Binary consumed everything. Base/qualifiers/projection must be empty. if s: @@ -1968,7 +2095,7 @@ def _parse_uncached( f"binary operator {binary_terminator.op!r} cannot combine with " "residue; got unexpected trailing content" ) - # Synthesise a placeholder base so the outer IR validates. The + # Synthesize a placeholder base so the outer IR validates. The # binary operator lives on the outer IR's operators stack and its # args carry the real structure. The placeholder is never rendered. # The full operator stack — trailing postfix plus any @@ -1991,6 +2118,7 @@ def _parse_uncached( "empty residue after peeling operators and decorators", ) base, qualifiers, projection = _match_base_with_qualifiers(s, v) + qualifiers = [*repositioned_qualifiers, *qualifiers] ir = StandardNameIR( operators=operator_stack, diff --git a/imas_standard_names/grammar/render.py b/imas_standard_names/grammar/render.py index 142f2a81..3998e6d5 100644 --- a/imas_standard_names/grammar/render.py +++ b/imas_standard_names/grammar/render.py @@ -11,11 +11,10 @@ See the rendering templates in ``vocabularies/operators.yml``. """ -from __future__ import annotations - from collections.abc import Iterable from imas_standard_names.grammar.ir import ( + BARE_PREFIX_OPERATORS, AxisProjection, LocusRef, OperatorApplication, @@ -128,8 +127,8 @@ def render_mechanism(mechanism: Process | None) -> str: def _render_base_with_decorators(ir: StandardNameIR) -> str: """Render the projection + qualifiers + base + locus + mechanism core. - This is the inner function the operator stack wraps around. It does - **not** include operator decoration. See :func:`render_operators`. + This function does not include operator decoration. The canonical composer + may render it whole or render a tail-free copy before inserting operators. """ parts: list[str] = [] @@ -162,6 +161,8 @@ def _render_operator_stack( operators: list[OperatorApplication], inner: str, enclosing_ir: StandardNameIR, + *, + inside_composite_operand: bool, ) -> str: """Recursively apply the operator stack outer-to-inner. @@ -181,9 +182,16 @@ def _render_operator_stack( # as the operator's operand instead of the inner stream. This is # how nested operator trees are represented (args: [sub_ir]). if op.args: - operand = compose(op.args[0]) + operand = _compose( + op.args[0], inside_composite_operand=inside_composite_operand + ) else: - operand = _render_operator_stack(rest, inner, enclosing_ir) + operand = _render_operator_stack( + rest, + inner, + enclosing_ir, + inside_composite_operand=inside_composite_operand, + ) rest = [] if op.bare_prefix: # Joiner-free spelling (flux_surface_averaged_ratio_of_A_to_B). The @@ -191,18 +199,40 @@ def _render_operator_stack( outer = f"{op.op}_{operand}" else: assert_operator_of_form(op, registry=None) - outer = f"{op.op}_of_{operand}" + indexed_parts = op.op.partition("_with_respect_to_") + if indexed_parts[1] and indexed_parts[0] and indexed_parts[2]: + operator, _, index = indexed_parts + outer = f"{operator}_of_{operand}_with_respect_to_{index}" + else: + outer = f"{op.op}_of_{operand}" # Any remaining operators in ``rest`` still need to wrap the result. - return _render_operator_stack(rest, outer, enclosing_ir) + return _render_operator_stack( + rest, + outer, + enclosing_ir, + inside_composite_operand=inside_composite_operand, + ) if op.kind is OperatorKind.UNARY_POSTFIX: if op.args: - operand = compose(op.args[0]) + operand = _compose( + op.args[0], inside_composite_operand=inside_composite_operand + ) else: - operand = _render_operator_stack(rest, inner, enclosing_ir) + operand = _render_operator_stack( + rest, + inner, + enclosing_ir, + inside_composite_operand=inside_composite_operand, + ) rest = [] outer = f"{operand}_{op.op}" - return _render_operator_stack(rest, outer, enclosing_ir) + return _render_operator_stack( + rest, + outer, + enclosing_ir, + inside_composite_operand=inside_composite_operand, + ) if op.kind is OperatorKind.BINARY: assert_binary_has_separator(op, registry=None) @@ -210,10 +240,15 @@ def _render_operator_stack( raise RenderError( f"binary operator {op.op!r} requires 2 args, got {len(op.args)}" ) - a = compose(op.args[0]) - b = compose(op.args[1]) + a = _compose(op.args[0], inside_composite_operand=True) + b = _compose(op.args[1], inside_composite_operand=True) outer = f"{op.op}_of_{a}_{op.separator}_{b}" - return _render_operator_stack(rest, outer, enclosing_ir) + return _render_operator_stack( + rest, + outer, + enclosing_ir, + inside_composite_operand=inside_composite_operand, + ) raise RenderError( # pragma: no cover - StrEnum is exhaustive f"unknown operator kind {op.kind!r} for operator {op.op!r}" @@ -237,7 +272,105 @@ def render_operators( if enclosing_ir is None: # Build a trivial no-op context: we need only identity for diagnostics. enclosing_ir = operators[0].args[0] if operators[0].args else None # type: ignore[assignment] - return _render_operator_stack(operators, inner, enclosing_ir) # type: ignore[arg-type] + return _render_operator_stack( # type: ignore[arg-type] + operators, + inner, + enclosing_ir, + inside_composite_operand=False, + ) + + +def _contains_composite_expression(ir: StandardNameIR) -> bool: + """Return whether ``ir`` contains a binary operand tree.""" + + for operator in ir.operators: + if operator.kind is OperatorKind.BINARY: + return True + if any(_contains_composite_expression(argument) for argument in operator.args): + return True + return False + + +def _has_repositioning_tail(ir: StandardNameIR) -> bool: + """Return whether ``ir`` carries an ``of``, ``at``, or ``due_to`` tail.""" + + if ir.mechanism is not None: + return True + return ir.locus is not None and ir.locus.relation.value in {"of", "at"} + + +def _leading_bare_operators( + ir: StandardNameIR, +) -> tuple[list[Qualifier], list[Qualifier]]: + """Split leading bare operators from ordinary base qualifiers.""" + + operator_qualifiers: list[Qualifier] = [] + ordinary_qualifiers = list(ir.qualifiers) + while ordinary_qualifiers and ordinary_qualifiers[0].token in BARE_PREFIX_OPERATORS: + operator_qualifiers.append(ordinary_qualifiers.pop(0)) + return operator_qualifiers, ordinary_qualifiers + + +def _can_reposition_operators( + ir: StandardNameIR, + *, + inside_composite_operand: bool, +) -> bool: + """Return whether local unary operators may sit immediately before the tail.""" + + if inside_composite_operand or not _has_repositioning_tail(ir): + return False + if _contains_composite_expression(ir): + return False + return all( + operator.kind is not OperatorKind.BINARY + and not operator.args + and "_with_respect_to_" not in operator.op + and operator.op not in BARE_PREFIX_OPERATORS + for operator in ir.operators + ) + + +def _compose( + ir: StandardNameIR, + *, + inside_composite_operand: bool, +) -> str: + """Render one IR while retaining composite-operand context recursively.""" + + operator_qualifiers, ordinary_qualifiers = _leading_bare_operators(ir) + can_reposition = _can_reposition_operators( + ir, inside_composite_operand=inside_composite_operand + ) + has_local_operator = bool(ir.operators or operator_qualifiers) + + if can_reposition and has_local_operator: + base_ir = ir.model_copy( + update={ + "operators": [], + "qualifiers": ordinary_qualifiers, + "locus": None, + "mechanism": None, + } + ) + rendered = _render_base_with_decorators(base_ir) + operator_tokens = [ + *(operator.op for operator in ir.operators), + *(qualifier.token for qualifier in operator_qualifiers), + ] + for token in reversed(operator_tokens): + rendered += f"_{token}" + rendered += render_locus(ir.locus) + rendered += render_mechanism(ir.mechanism) + return rendered + + inner = _render_base_with_decorators(ir) + return _render_operator_stack( + list(ir.operators), + inner, + ir, + inside_composite_operand=inside_composite_operand, + ) # --------------------------------------------------------------------------- @@ -267,8 +400,7 @@ def compose(ir: StandardNameIR) -> str: ) try: - inner = _render_base_with_decorators(ir) - rendered = _render_operator_stack(list(ir.operators), inner, ir) + rendered = _compose(ir, inside_composite_operand=False) except RenderError: raise except ValueError as exc: @@ -285,7 +417,10 @@ def compose(ir: StandardNameIR) -> str: # When the outermost operator pushes text after the locus suffix, the # resulting name violates the trailing-position invariant and must be # rejected rather than emitted. - if ir.locus is not None and not ir.operators: + if ir.locus is not None and ( + not ir.operators + or _can_reposition_operators(ir, inside_composite_operand=False) + ): try: assert_locus_is_trailing(rendered, ir) except ValueError as exc: diff --git a/imas_standard_names/grammar/vocab_loaders.py b/imas_standard_names/grammar/vocab_loaders.py index b33dbfd3..1342e7ff 100644 --- a/imas_standard_names/grammar/vocab_loaders.py +++ b/imas_standard_names/grammar/vocab_loaders.py @@ -183,6 +183,7 @@ class PhysicalBaseDef(BaseModel, extra="forbid"): spatial domain (peak/maximum/minimum) is a reduction transformation and must be spelled maximum_of__... / minimum_of__...; an infix extremum qualifier inside the base is rejected. + definition: Normative term definition exposed to grammar consumers. """ aliases: list[str] = [] @@ -190,6 +191,7 @@ class PhysicalBaseDef(BaseModel, extra="forbid"): inherently_dimensional: bool = False constant_on_flux_surface: bool = False extremum_is_transformation: bool = False + definition: str = "" class PhysicalBasesRegistry(BaseModel, extra="forbid"): diff --git a/imas_standard_names/grammar/vocabularies/components.yml b/imas_standard_names/grammar/vocabularies/components.yml index cff32a9f..ac8c51b7 100644 --- a/imas_standard_names/grammar/vocabularies/components.yml +++ b/imas_standard_names/grammar/vocabularies/components.yml @@ -46,16 +46,25 @@ # normalized coordinate direction is a genuine, distinct direction, so the # `normalized_` prefix is a real component token. # -# `parallel` and `perpendicular` are deliberately ABSENT here: they are -# field-RELATIVE directions (along / across B) with no separate "normalized" +# `parallel` and `perpendicular` are deliberately absent here: they are +# field-relative directions (along / across B) with no separate "normalized" # variant of the direction itself. In a name like # `normalized_parallel_momentum_flux`, `normalized` is the GyroBohm/reference -# normalization OPERATOR applied to the parallel momentum flux, not a +# normalization operator applied to the parallel momentum flux, not a # direction. Registering `normalized_parallel`/`normalized_perpendicular` as # component tokens shadowed that operator reading via longest-match; they are # removed so the operator peels and the projection resolves to -# `parallel`/`perpendicular` (canonical-qualifier-order §3). +# `parallel`/`perpendicular` under the qualifier-ordering rule. - normalized_radial # projection onto the ρ (normalized flux radius) direction - normalized_vertical # projection onto the normalized vertical (Z) direction - normalized_toroidal # projection onto the φ (toroidal angle) direction - normalized_poloidal # projection onto the θ (poloidal angle) direction + +# === Stokes-vector components === +# Dimensionless polarization coordinates. The FOCS DD stores the initial and +# output Stokes vectors as eight scalar leaves whose documentation identifies +# S0 through S3 as components of one Stokes vector. +- s0 +- s1 +- s2 +- s3 diff --git a/imas_standard_names/grammar/vocabularies/geometry_carriers.yml b/imas_standard_names/grammar/vocabularies/geometry_carriers.yml index 9b4a530a..4d9f9236 100644 --- a/imas_standard_names/grammar/vocabularies/geometry_carriers.yml +++ b/imas_standard_names/grammar/vocabularies/geometry_carriers.yml @@ -32,15 +32,25 @@ carriers: normalized_toroidal_flux_coordinate: aliases: [] constant_on_flux_surface: true + definition: > + A dimensionless radial label equal to the square root of the normalized + toroidal flux, spanning the magnetic axis to the equilibrium boundary. offset: aliases: [] position: aliases: [] radial_coordinate: aliases: [] + definition: > + The major-radius coordinate locating a geometric point or reference + position by perpendicular distance from the toroidal symmetry axis. toroidal_flux_coordinate: aliases: [] constant_on_flux_surface: true + definition: > + A non-negative, radius-like label of a nested magnetic flux surface, + derived from enclosed toroidal magnetic flux using a positive reference + vacuum toroidal field. # === Migrated from geometric_bases.yml (carrier-type entries) === contour: aliases: [] @@ -58,6 +68,10 @@ carriers: normalized_poloidal_flux_coordinate: aliases: [] constant_on_flux_surface: true + definition: > + A radial label for nested magnetic flux surfaces based on poloidal + magnetic flux, zero at the magnetic axis and one at the last closed flux + surface. poloidal_magnetic_flux_coordinate: aliases: [] constant_on_flux_surface: true diff --git a/imas_standard_names/grammar/vocabularies/operators.yml b/imas_standard_names/grammar/vocabularies/operators.yml index ca89f0f6..6005b9dd 100644 --- a/imas_standard_names/grammar/vocabularies/operators.yml +++ b/imas_standard_names/grammar/vocabularies/operators.yml @@ -18,6 +18,9 @@ # unary_prefix -> _of_ # unary_postfix -> _ # binary -> _of_ +# A unary operator on an ordinary base with an ``of``, ``at``, or ``due_to`` +# tail renders immediately before that tail. Operators within either operand +# of a binary expression retain the templates above at every nesting depth. # # Unified registry of prefix, postfix, and binary operators. @@ -187,7 +190,7 @@ operators: returns: scalar dimension_transforming: true - # Finite/discrete increment (Delta) of the base over an event — NOT a rate. + # Finite/discrete increment (Delta) of the base over an event, not a rate. # Unlike time_derivative (which returns a per-time rate and renders # with _of_), change_in preserves the base unit and renders BARE # (change_in_electron_density). It therefore also appears in the diff --git a/imas_standard_names/grammar/vocabularies/physical_bases.yml b/imas_standard_names/grammar/vocabularies/physical_bases.yml index f2fb3392..fb350f5b 100644 --- a/imas_standard_names/grammar/vocabularies/physical_bases.yml +++ b/imas_standard_names/grammar/vocabularies/physical_bases.yml @@ -43,9 +43,16 @@ bases: atomic_number: aliases: [nuclear_charge_number] kind: scalar + definition: > + Nuclear proton count identifying the selected element in a plasma or + neutral-particle species, independent of isotope and ionization state. beta: aliases: [] kind: scalar + definition: > + Plasma beta, denoted beta, is a dimensionless measure of total plasma + pressure relative to a magnetic-pressure scale. The poloidal and toroidal + components are related to it by 1/beta = 1/beta_poloidal + 1/beta_toroidal. capacitance: aliases: [] kind: scalar @@ -86,6 +93,10 @@ bases: current_density: aliases: [] kind: vector + definition: > + The vector flux density of conventional electric charge, equal to + electric current per unit area normal to the flow, including all + charged-particle contributions. debye_length: aliases: [] kind: scalar @@ -108,6 +119,10 @@ bases: coulomb_logarithm: aliases: [] kind: scalar + definition: > + The Coulomb logarithm (ln Lambda) representing the magnitude of + small-angle collective scattering interactions, governing collision + frequencies. damping_rate: aliases: [] kind: scalar @@ -121,12 +136,20 @@ bases: aliases: [] kind: vector inherently_dimensional: true + definition: > + The local vector electric field, equal to the force per unit charge on a + positive stationary test charge, including electrostatic, inductive, and + electromagnetic-wave contributions. electrical_conductivity: aliases: [] kind: scalar electrostatic_potential: aliases: [] kind: scalar + definition: > + The voltage-valued electrostatic potential giving the electric potential + energy per unit positive charge relative to a chosen electrical + reference. ellipticity: aliases: [] kind: scalar @@ -172,6 +195,10 @@ bases: flux_limiter_coefficient: aliases: [] kind: scalar + definition: > + A multiplicative factor, or vector of factors, that scales a collisionless + free-streaming bound on a flux density while preserving the direction or + sign of each resolved flux component. force: aliases: [] kind: vector @@ -213,10 +240,16 @@ bases: mach_number: aliases: [] kind: scalar + definition: > + Dimensionless ratio of plasma flow velocity to the local sound speed. magnetic_field: aliases: [] kind: vector inherently_dimensional: true + definition: > + The local total magnetic-induction vector in the right-handed cylindrical + (R, phi, Z) frame, not selecting a source contribution, perturbation + state, component, magnitude, or evaluation locus. magnetic_pressure: aliases: [] kind: scalar @@ -225,6 +258,9 @@ bases: aliases: [] kind: scalar constant_on_flux_surface: true + definition: > + The signed scalar surface integral of the magnetic field through an + oriented surface. magnetic_moment: aliases: [] kind: vector @@ -264,6 +300,10 @@ bases: aliases: [] kind: vector inherently_dimensional: true + definition: > + The linear momentum associated with plasma motion, covering momentum + content in a plasma volume or momentum transported through an oriented + surface. multiplicity: aliases: [] kind: scalar @@ -316,6 +356,17 @@ bases: refractive_index: aliases: [] kind: scalar + definition: > + Dimensionless ratio of an electromagnetic wave's phase-gradient vector to + its vacuum wavenumber, retaining propagation direction and signed + components. + relative_humidity: + # Water-vapour partial pressure relative to its saturation value. The + # detector-humidity DD source stores the fraction on the interval 0..1, + # so this is an irreducible dimensionless scalar rather than a qualifier + # applied to another humidity base. + aliases: [] + kind: scalar residual: aliases: [] kind: scalar @@ -334,6 +385,10 @@ bases: aliases: [] kind: scalar constant_on_flux_surface: true + definition: > + The signed ratio of toroidal to poloidal field-line winding on a magnetic + flux surface, equal to the toroidal turns made during one poloidal + circuit. shearing_rate: aliases: [] kind: scalar @@ -387,6 +442,10 @@ bases: toroidal_flux_coordinate_gradient: aliases: [] kind: vector + definition: > + The spatial gradient family of a flux-surface label based on enclosed + toroidal magnetic flux, without fixing a norm, projection, component, or + flux-surface average. torque: aliases: [] kind: vector @@ -394,6 +453,9 @@ bases: torque_density: aliases: [] kind: vector + definition: > + The local vector rate of net angular-momentum transfer per unit volume to + or from the plasma. transit_time: aliases: [] kind: scalar @@ -418,6 +480,9 @@ bases: vorticity: aliases: [] kind: scalar + definition: > + The curl of the plasma bulk velocity field, measuring the signed local + angular rotation of fluid elements. photon_energy: aliases: [] kind: scalar @@ -520,6 +585,9 @@ bases: aliases: [diamagnetic_function] kind: scalar inherently_dimensional: true + definition: > + The diamagnetic function F = R B_phi, the major-radius-weighted toroidal + magnetic-field function used in axisymmetric equilibria. flux_surface_averaged_metric: # flux-surface-averaged geometric metric coefficient (the equilibrium # gm1..gm9 family: <1/R^2>, <|grad rho|^2>, ...); the specific moment is @@ -612,6 +680,10 @@ bases: aliases: [] kind: vector inherently_dimensional: true + definition: > + A vector field whose curl gives the magnetic induction, gauge-dependent + but central to canonical-momentum and gyrokinetic formulations of + magnetized plasmas. larmor_radius: # gyroradius of a charged particle in a magnetic field (m); enables the # dimensionless normalized_larmor_radius (rho*) via the normalized operator. @@ -670,6 +742,9 @@ bases: # dimensionless tritium breeding ratio (TBR) of a blanket. aliases: [] kind: scalar + definition: > + The blanket-level ratio of tritium atoms produced in breeding regions to + the incident fusion-neutron count over the same interval (TBR). fusion_gain: # dimensionless fusion energy gain Q (fusion power / input heating power). aliases: [] @@ -773,12 +848,18 @@ bases: aliases: [] kind: vector inherently_dimensional: true + definition: > + A magnetic vector potential, covering the full potential, its directional + components, and fluctuations about an equilibrium potential. center_of_mass_velocity: # bulk velocity of the centre-of-mass frame; center_of_mass binds to # velocity as a single lexical quantity. aliases: [] kind: vector inherently_dimensional: true + definition: > + The mass-weighted barycentric velocity vector of a multicomponent plasma, + equal to total momentum density divided by total mass density. angular_momentum: # angular momentum L (kg.m^2.s^-1); angular binds to momentum. aliases: [] diff --git a/tests/grammar/test_binary_locus_scope.py b/tests/grammar/test_binary_locus_scope.py index 064b39a0..5a93c6bb 100644 --- a/tests/grammar/test_binary_locus_scope.py +++ b/tests/grammar/test_binary_locus_scope.py @@ -126,7 +126,7 @@ def test_repeated_terminal_locus_is_rejected() -> None: @pytest.mark.parametrize( "name", [ - "time_derivative_of_pressure_at_magnetic_axis", + "pressure_time_derivative_at_magnetic_axis", "ratio_of_pressure_to_temperature", "difference_of_total_pressure_and_electron_pressure", ], diff --git a/tests/grammar/test_binary_operand_vocabulary.py b/tests/grammar/test_binary_operand_vocabulary.py index ddab3580..958580b3 100644 --- a/tests/grammar/test_binary_operand_vocabulary.py +++ b/tests/grammar/test_binary_operand_vocabulary.py @@ -84,8 +84,8 @@ def test_unregistered_binary_operand_is_rejected(name: str, operand: str) -> Non def test_un_normalized_poloidal_flux_coordinate_is_indexable() -> None: name = ( - "derivative_with_respect_to_poloidal_magnetic_flux_coordinate" - "_of_poloidal_current_function" + "derivative_of_poloidal_current_function" + "_with_respect_to_poloidal_magnetic_flux_coordinate" ) assert parse_standard_name(name).transformation == ( @@ -95,7 +95,7 @@ def test_un_normalized_poloidal_flux_coordinate_is_indexable() -> None: def test_toroidal_flux_coordinate_is_canonical_and_radius_is_rejected() -> None: coordinate = parse_standard_name( - "derivative_with_respect_to_toroidal_flux_coordinate_of_pressure" + "derivative_of_pressure_with_respect_to_toroidal_flux_coordinate" ) assert coordinate.transformation == ( "derivative_with_respect_to_toroidal_flux_coordinate" @@ -103,7 +103,7 @@ def test_toroidal_flux_coordinate_is_canonical_and_radius_is_rejected() -> None: with pytest.raises(ValueError) as excinfo: parse_standard_name( - "derivative_with_respect_to_toroidal_flux_radius_of_pressure" + "derivative_of_pressure_with_respect_to_toroidal_flux_radius" ) assert not isinstance(excinfo.value, NonCanonicalNameError) diff --git a/tests/grammar/test_extremum_infix_flux_gate.py b/tests/grammar/test_extremum_infix_flux_gate.py index 57b16761..ec743be5 100644 --- a/tests/grammar/test_extremum_infix_flux_gate.py +++ b/tests/grammar/test_extremum_infix_flux_gate.py @@ -1,7 +1,7 @@ """Gate: extremum qualifier embedded as an infix inside a flux base. An extremum of a transport flux over a spatial domain (peak/maximum value on -a wall, a divertor target, …) is a reduction TRANSFORMATION. Its one canonical +a wall, a divertor target, …) is a reduction transformation. Its one canonical spelling puts the reduction in transformation (prefix) position — ``maximum_of__flux_at_`` / ``minimum_of_...``. The same token embedded as an infix qualifier inside the flux base (``energy_peak_flux``, @@ -14,8 +14,6 @@ transformation position stays legal. """ -from __future__ import annotations - import pytest from imas_standard_names.grammar.model import ( @@ -36,12 +34,12 @@ ] ACCEPTED = [ - # (a) the reduction TRANSFORMATION form is the canonical spelling - "maximum_of_energy_flux_at_first_wall", - "maximum_of_energy_flux_at_divertor_target", - "minimum_of_energy_flux_at_first_wall", - "maximum_of_heat_flux_at_divertor_target", - "maximum_of_particle_flux_at_first_wall", + # The reduction operator precedes the tail in the canonical spelling. + "energy_flux_maximum_at_first_wall", + "energy_flux_maximum_at_divertor_target", + "energy_flux_minimum_at_first_wall", + "heat_flux_maximum_at_divertor_target", + "particle_flux_maximum_at_first_wall", # (b) a plain flux base is unaffected "energy_flux", "energy_flux_at_first_wall", diff --git a/tests/grammar/test_flux_surface_reduction_gate.py b/tests/grammar/test_flux_surface_reduction_gate.py index 93c1f2fe..7bcca380 100644 --- a/tests/grammar/test_flux_surface_reduction_gate.py +++ b/tests/grammar/test_flux_surface_reduction_gate.py @@ -8,11 +8,9 @@ Such names must fail both parse and compose so the pipeline can never mint them; the local and averaged DD leaves share one name instead. -Volume/line averages of a flux function are NOT no-ops and stay legal. +Volume/line averages of a flux function are not no-ops and stay legal. """ -from __future__ import annotations - import pytest from imas_standard_names.grammar.model import ( @@ -36,10 +34,10 @@ ] ACCEPTED = [ - # surface-varying bases keep the reduction prefix - "flux_surface_averaged_electron_density_at_plasma_boundary", - "flux_surface_averaged_electron_temperature_at_plasma_boundary", - "toroidal_flux_surface_averaged_argon_velocity_at_plasma_boundary", + # Surface-varying bases keep the reduction immediately before the tail. + "electron_density_flux_surface_averaged_at_plasma_boundary", + "electron_temperature_flux_surface_averaged_at_plasma_boundary", + "toroidal_argon_velocity_flux_surface_averaged_at_plasma_boundary", "maximum_over_flux_surface_electron_temperature", # non-flux-surface reductions of flux functions remain legal "volume_averaged_plasma_pressure", diff --git a/tests/grammar/test_indexed_derivative_and_over_locus.py b/tests/grammar/test_indexed_derivative_and_over_locus.py index 634d00c7..ecc6ad7b 100644 --- a/tests/grammar/test_indexed_derivative_and_over_locus.py +++ b/tests/grammar/test_indexed_derivative_and_over_locus.py @@ -4,10 +4,9 @@ ``derivative_with_respect_to`` is declared in ``operators.yml`` as a ``unary_prefix`` with ``index_params: [coord]``. Its canonical prefix - form binds the coordinate index between the operator token and the - ``_of_`` separator:: + form places the coordinate index after the complete operand:: - derivative_with_respect_to__of_ + derivative_of__with_respect_to_ where ```` is a registered coordinate / flux-coordinate token (geometry-carrier vocabulary, e.g. ``radial_coordinate``, @@ -51,10 +50,10 @@ def vocabs() -> Vocabularies: # Coordinate tokens drawn from the registered coordinate / flux-coordinate # (geometry-carrier) vocabulary, paired with a registered physical base. _FLUX_DERIVATIVE_NAMES = [ - "derivative_with_respect_to_radial_coordinate_of_volume", - "derivative_with_respect_to_toroidal_flux_coordinate_of_pressure", - "derivative_with_respect_to_normalized_toroidal_flux_coordinate_of_pressure", - "derivative_with_respect_to_normalized_poloidal_flux_coordinate_of_volume", + "derivative_of_volume_with_respect_to_radial_coordinate", + "derivative_of_pressure_with_respect_to_toroidal_flux_coordinate", + "derivative_of_pressure_with_respect_to_normalized_toroidal_flux_coordinate", + "derivative_of_volume_with_respect_to_normalized_poloidal_flux_coordinate", ] @@ -78,7 +77,7 @@ def test_indexed_flux_derivative_round_trips(name: str, vocabs: Vocabularies) -> def test_indexed_flux_derivative_binds_coordinate(vocabs: Vocabularies) -> None: """The coordinate index is fused into the operator token; base is the inner.""" - name = "derivative_with_respect_to_radial_coordinate_of_volume" + name = "derivative_of_volume_with_respect_to_radial_coordinate" result = parse(name, vocabs=vocabs) op = result.ir.operators[0] assert op.op == "derivative_with_respect_to_radial_coordinate" @@ -94,14 +93,14 @@ def test_indexed_flux_derivative_rejects_unregistered_coordinate( its index and the residue does not resolve to a base. """ with pytest.raises(ParseError): - parse("derivative_with_respect_to_banana_of_volume", vocabs=vocabs) + parse("derivative_of_volume_with_respect_to_banana", vocabs=vocabs) def test_indexed_flux_derivative_nested_under_maximum( vocabs: Vocabularies, ) -> None: """Indexed derivative nests under an outer prefix operator and round-trips.""" - name = "maximum_of_derivative_with_respect_to_radial_coordinate_of_pressure" + name = "maximum_of_derivative_of_pressure_with_respect_to_radial_coordinate" result = parse(name, vocabs=vocabs) assert [op.op for op in result.ir.operators] == [ "maximum", diff --git a/tests/grammar/test_indexed_derivative_model_round_trip.py b/tests/grammar/test_indexed_derivative_model_round_trip.py index 8c8ae816..e3d11b77 100644 --- a/tests/grammar/test_indexed_derivative_model_round_trip.py +++ b/tests/grammar/test_indexed_derivative_model_round_trip.py @@ -3,7 +3,7 @@ ``derivative_with_respect_to`` is an indexed unary-prefix operator (``index_params: [coord]``). The parser fuses the coordinate index into the operator token (``derivative_with_respect_to_``) so the canonical form -``derivative_with_respect_to__of_`` round-trips. The downstream +``derivative_of__with_respect_to_`` round-trips. The downstream composer goes through the flat ``StandardName`` model (``parse_standard_name`` / ``compose_standard_name``), where the fused token must be accepted in the ``transformation`` slot even though it is not a bare @@ -34,7 +34,7 @@ _BASES = ["volume", "pressure", "area"] _INDEXED_DERIVATIVE_NAMES = [ - f"derivative_with_respect_to_{coord}_of_{base}" + f"derivative_of_{base}_with_respect_to_{coord}" for coord, base in itertools.product(_COORDS, _BASES) ] @@ -53,7 +53,7 @@ def test_indexed_derivative_carries_fused_transformation(name: str) -> None: assert model.transformation is not None assert model.transformation.startswith("derivative_with_respect_to_") # The coordinate index is preserved verbatim in the fused token. - coord = name[len("derivative_with_respect_to_") : name.index("_of_")] + _, _, coord = name.partition("_with_respect_to_") assert model.transformation == f"derivative_with_respect_to_{coord}" diff --git a/tests/grammar/test_indexed_operator_order.py b/tests/grammar/test_indexed_operator_order.py new file mode 100644 index 00000000..6cf184f1 --- /dev/null +++ b/tests/grammar/test_indexed_operator_order.py @@ -0,0 +1,73 @@ +"""Canonical ordering for coordinate-indexed unary operators.""" + +from imas_standard_names.grammar.ir import ( + BaseKind, + LocusRef, + LocusRelation, + LocusType, + OperatorApplication, + OperatorKind, + QuantityOrCarrier, + StandardNameIR, +) +from imas_standard_names.grammar.parser import parse +from imas_standard_names.grammar.render import compose + + +def _flux_surface_quantity(base: str) -> StandardNameIR: + return StandardNameIR( + operators=[ + OperatorApplication( + kind=OperatorKind.UNARY_PREFIX, + op="derivative_with_respect_to_toroidal_flux_coordinate", + ) + ], + base=QuantityOrCarrier(token=base, kind=BaseKind.QUANTITY), + locus=LocusRef( + relation=LocusRelation.OF, + token="flux_surface", + type=LocusType.POSITION, + ), + ) + + +def test_indexed_operator_renders_operand_before_index() -> None: + ir = _flux_surface_quantity("area") + rendered = compose(ir) + + assert rendered == ( + "derivative_of_area_of_flux_surface_with_respect_to_toroidal_flux_coordinate" + ) + assert parse(rendered, strict=True).ir == ir + + +def test_nested_indexed_operator_round_trips_operand_first() -> None: + indexed = _flux_surface_quantity("volume") + ir = indexed.model_copy( + update={ + "operators": [ + OperatorApplication( + kind=OperatorKind.UNARY_PREFIX, + op="time_derivative", + ), + *indexed.operators, + ] + } + ) + rendered = compose(ir) + + assert rendered == ( + "time_derivative_of_derivative_of_volume_of_flux_surface_" + "with_respect_to_toroidal_flux_coordinate" + ) + assert parse(rendered, strict=True).ir == ir + + +def test_composite_ratio_operands_keep_their_operator_positions() -> None: + name = ( + "flux_surface_averaged_ratio_of_square_of_" + "toroidal_flux_coordinate_gradient_magnitude_" + "to_square_of_major_radius" + ) + + assert compose(parse(name, strict=True).ir) == name diff --git a/tests/grammar/test_ir_and_render.py b/tests/grammar/test_ir_and_render.py index a0a13901..71ea1433 100644 --- a/tests/grammar/test_ir_and_render.py +++ b/tests/grammar/test_ir_and_render.py @@ -1,4 +1,4 @@ -"""Tests for grammar IR model and canonical renderer (plan 38 W1a). +"""Tests for the grammar IR model and canonical renderer. These tests validate the IR shape constraints and the canonical strings produced by :func:`imas_standard_names.grammar.render.compose`. @@ -29,12 +29,12 @@ from imas_standard_names.grammar.render import RenderError, compose # --------------------------------------------------------------------------- -# Canonical rendering — plan §A2 / §A12 examples +# Canonical rendering examples # --------------------------------------------------------------------------- def test_compose_simple_locus(): - """Row 1 from §A12: ``elongation_of_plasma_boundary``.""" + """Render ``elongation_of_plasma_boundary`` with an entity locus.""" ir = StandardNameIR( base=QuantityOrCarrier(token="elongation", kind=BaseKind.QUANTITY), locus=LocusRef( @@ -47,7 +47,7 @@ def test_compose_simple_locus(): def test_compose_projection_plus_qualifier_plus_locus(): - """§A2 example: radial component, electron qualifier, at plasma_boundary.""" + """Render a radial component, electron qualifier, and position locus.""" ir = StandardNameIR( projection=AxisProjection(axis="radial", shape=ProjectionShape.COMPONENT), qualifiers=[Qualifier(token="electron")], @@ -62,7 +62,7 @@ def test_compose_projection_plus_qualifier_plus_locus(): def test_compose_nested_prefix_operators(): - """Row 14 from §A12: nested ``maximum_of`` / ``derivative`` + locus.""" + """Render nested ``maximum_of`` and ``derivative`` operators with a locus.""" ir = StandardNameIR( operators=[ OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op="maximum"), @@ -80,8 +80,8 @@ def test_compose_nested_prefix_operators(): ), ) assert compose(ir) == ( - "maximum_of_derivative_with_respect_to_normalized_poloidal_flux_of_" - "electron_pressure_at_pedestal" + "maximum_of_derivative_of_electron_pressure_at_pedestal_" + "with_respect_to_normalized_poloidal_flux" ) @@ -133,7 +133,7 @@ def test_compose_mechanism_trails_locus(): def test_compose_coordinate_shape_on_geometry_carrier(): - """``vertical_`` — §A12 row 23.""" + """Render ``vertical_`` as a coordinate projection.""" ir = StandardNameIR( projection=AxisProjection(axis="vertical", shape=ProjectionShape.COORDINATE), base=QuantityOrCarrier(token="position", kind=BaseKind.GEOMETRY), @@ -243,7 +243,7 @@ def test_invalid_token_shape_rejected(): # --------------------------------------------------------------------------- -# §A3 assertion helpers +# Operator-form assertion helpers # --------------------------------------------------------------------------- diff --git a/tests/grammar/test_model_parser_operator_parity.py b/tests/grammar/test_model_parser_operator_parity.py index 734757a0..4e7893d3 100644 --- a/tests/grammar/test_model_parser_operator_parity.py +++ b/tests/grammar/test_model_parser_operator_parity.py @@ -48,7 +48,8 @@ def _candidate_names(op: str, meta: dict) -> list[str]: Returns an ordered list of candidates; the first that round-trips through both layers is the one asserted on. The variants cover the structural - forms: indexed-prefix (fused ``__of_``), bare-vs-``_of_`` + forms: indexed-prefix (``derivative_of__with_respect_to_``), + bare-vs-``_of_`` prefix, postfix tail, and binary ``_of___``. """ kind = meta["kind"] @@ -64,8 +65,8 @@ def _candidate_names(op: str, meta: dict) -> list[str]: # flux-surface reductions gate it out — use a surface-varying base. return [f"{op}_of_temperature", f"{op}_temperature"] if meta.get("indexed") and list(meta.get("index_params") or []) == ["coord"]: - # Fused indexed prefix: __of_. - return [f"{op}_radial_coordinate_of_{_SCALAR_BASE}"] + operator, relation, _ = op.partition("_with_respect_to") + return [f"{operator}_of_{_SCALAR_BASE}{relation}_radial_coordinate"] # Some prefix operators render with ``_of_`` (gradient_of_pressure); # the bare-prefix family renders without it (normalized_pressure). Try # both and keep whichever round-trips. diff --git a/tests/grammar/test_nested_operator_round_trip.py b/tests/grammar/test_nested_operator_round_trip.py index 5fc97220..170d347f 100644 --- a/tests/grammar/test_nested_operator_round_trip.py +++ b/tests/grammar/test_nested_operator_round_trip.py @@ -20,8 +20,6 @@ limitation and must keep raising rather than silently dropping tokens. """ -from __future__ import annotations - import pytest from imas_standard_names.grammar.model import ( @@ -41,7 +39,7 @@ # postfix outer operator + inner bare-transformation "volume_averaged_magnetic_field_magnitude", # outer operator + inner bare-transformation + trailing locus - "maximum_of_volume_averaged_electron_density_at_magnetic_axis", + "electron_density_volume_averaged_maximum_at_magnetic_axis", # of-prefix transformation wrapping a projection axis: the projection # stays in `component`, no fold, so it round-trips (transformation × # component coexistence). diff --git a/tests/grammar/test_operator_locus_nesting.py b/tests/grammar/test_operator_locus_nesting.py index 14dfce07..99cef09b 100644 --- a/tests/grammar/test_operator_locus_nesting.py +++ b/tests/grammar/test_operator_locus_nesting.py @@ -1,44 +1,29 @@ """Model-level round-trip for postfix operators stacked with a locus, mechanism, or a prefix transformation. -These real-catalog forms combine a trailing **postfix** decomposition -operator (``magnitude``) with a locus/mechanism suffix, or nest a prefix -transformation with a postfix decomposition. The renderer -(:func:`compose`) already emits all of them — the postfix operator wraps -the ``base + locus + mechanism`` core, so the canonical spelling is -**base + locus/mechanism + postfix-op** (the postfix token sits at the very -end of the string, after the locus/mechanism suffix). - -The parse side previously could not recover these IRs: - -* Stage-1 mechanism strip and stage-2 locus strip ran BEFORE operator - peeling and greedily absorbed the trailing postfix token into the - process/locus token. ``velocity_due_to_pellet_injection_magnitude`` - parsed to a fabricated process token ``pellet_injection_magnitude`` - (silent loss of the ``magnitude`` operator); - ``magnetic_field_of_iron_core_segment_magnitude`` failed to match any - locus and then any base. -* The flat :class:`StandardName` model's ``_check_decomposition_exclusivity`` - validator forbade a prefix ``transformation`` and a postfix - ``decomposition`` from coexisting, so ``maximum_of_magnetic_field_magnitude`` - raised even though the two operators occupy distinct, non-ambiguous slots - (prefix renders ``maximum_of_<...>``; postfix renders ``<...>_magnitude``). +These real-catalog forms combine a **postfix** decomposition operator +(``magnitude``) with a locus/mechanism suffix, or nest a prefix transformation +with a postfix decomposition. The renderer places the operator immediately +after the base group and before the tail. + +Mechanism and locus stripping must preserve the postfix token as an operator, +rather than absorbing it into a fabricated process or locus token. The flat +:class:`StandardName` model also retains prefix and postfix operators in their +distinct transformation and decomposition slots. Canonical-order decisions encoded here: -* postfix-after-locus / postfix-after-mechanism is canonical - (``..._of_iron_core_segment_magnitude``, ``..._due_to__magnitude``); +* postfix-before-locus / postfix-before-mechanism is canonical + (``..._magnitude_of_iron_core_segment``, ``..._magnitude_due_to_``); * prefix transformation is outermost, postfix decomposition innermost (``maximum_of__magnitude``); -* a bare-prefix transformation qualifier (``maximum_``) coexists with a - locus and a postfix operator (``maximum__of__magnitude``). +* an extremum transformation qualifier (``maximum_``) coexists with a + locus and a postfix operator (``maximum__magnitude_of_``). The lossless-canonical guard remains the safety net: any name the flat -model cannot represent must RAISE, never drop a token. +model cannot represent must raise, never drop a token. """ -from __future__ import annotations - import pytest from imas_standard_names.grammar.model import ( @@ -54,18 +39,18 @@ # magnitude -> unary_postfix operator # maximum -> unary_prefix operator (and bare-prefix qualifier) IN_SCOPE = [ - # postfix op after an entity locus - "magnetic_field_of_iron_core_segment_magnitude", - # postfix op after a mechanism - "velocity_due_to_pellet_injection_magnitude", + # Postfix operator before an entity locus. + "magnetic_field_magnitude_of_iron_core_segment", + # Postfix operator before a mechanism. + "velocity_magnitude_due_to_pellet_injection", # prefix transformation (of-form) wrapping a postfix decomposition "maximum_of_magnetic_field_magnitude", # bare-prefix transformation qualifier + locus + postfix decomposition - "maximum_magnetic_field_of_flux_surface_magnitude", + "maximum_magnetic_field_magnitude_of_flux_surface", ] # Component pieces that already round-tripped in isolation and must keep -# doing so (regression guard — the staged-parse reorder must not disturb +# doing so (regression guard — the parser reorder must not disturb # the simpler forms). ALREADY_WORKING = [ "magnetic_field_magnitude", @@ -91,7 +76,7 @@ def test_already_working_names_still_round_trip(name: str) -> None: def test_mechanism_postfix_does_not_fabricate_process_token() -> None: - """The postfix operator must NOT be absorbed into the process token. + """The postfix operator must not be absorbed into the process token. The danger case: a greedy mechanism strip swallows ``_magnitude`` into a fabricated ``pellet_injection_magnitude`` process token (not in the closed @@ -99,14 +84,14 @@ def test_mechanism_postfix_does_not_fabricate_process_token() -> None: string, masking the lost operator. Assert the model carries the real process token and a real decomposition slot. """ - model = parse_standard_name("velocity_due_to_pellet_injection_magnitude") + model = parse_standard_name("velocity_magnitude_due_to_pellet_injection") dump = model.model_dump_compact() assert dump.get("process") == "pellet_injection" assert dump.get("decomposition") == "magnitude" def test_prefix_postfix_nest_populates_both_slots() -> None: - """``maximum_of_magnetic_field_magnitude`` carries BOTH operator slots. + """``maximum_of_magnetic_field_magnitude`` carries both operator slots. Prefix transformation and postfix decomposition occupy distinct, non-ambiguous slots (prefix renders ``maximum_of_<...>``, postfix renders diff --git a/tests/grammar/test_ordered_operator_chain_validation.py b/tests/grammar/test_ordered_operator_chain_validation.py index dec1c331..9a852150 100644 --- a/tests/grammar/test_ordered_operator_chain_validation.py +++ b/tests/grammar/test_ordered_operator_chain_validation.py @@ -212,17 +212,15 @@ def test_unprojectable_operator_chain_does_not_hide_invalid_core(core: str) -> N @pytest.mark.parametrize( - "core", + "name", [ - "electron_density", - "trapped_fast_ion_density", - "electron_density_due_to_non_inductive_current_drive", - "electron_density_at_magnetic_axis", + "maximum_of_inverse_of_electron_density", + "maximum_of_inverse_of_trapped_fast_ion_density", + "electron_density_inverse_maximum_due_to_non_inductive_current_drive", + "electron_density_inverse_maximum_at_magnetic_axis", ], ) -def test_unprojectable_operator_chain_accepts_valid_core(core: str) -> None: - name = f"maximum_of_inverse_of_{core}" - +def test_unprojectable_operator_chain_accepts_valid_core(name: str) -> None: assert compose(parse(name, strict=True).ir) == name diff --git a/tests/grammar/test_parser.py b/tests/grammar/test_parser.py index 5e5ba1ff..a46af35a 100644 --- a/tests/grammar/test_parser.py +++ b/tests/grammar/test_parser.py @@ -1,16 +1,13 @@ -"""Unit tests for the grammar parser (plan 38 W2b). +"""Unit tests for the grammar parser. -These tests exercise the staged parser against fixture-injected closed -vocabularies (physical_bases and locus_registry stubs will be populated -by W2a; the parser itself does not depend on YAML contents). +These tests exercise the parser against fixture-injected closed vocabularies; +the parser itself does not depend on YAML contents. For every canonical grammar name the test asserts ``compose(parse(name).ir) == name`` (round-trip). For non-canonical and error cases, the test asserts specific diagnostic / exception behaviour. """ -from __future__ import annotations - import pytest from imas_standard_names.grammar.ir import ( @@ -39,9 +36,8 @@ def vocabs() -> Vocabularies: """Inject a minimal vocabulary covering all test names. - This fixture bypasses YAML loading so tests remain stable while W2a - populates the vocabulary files. Every base, locus, operator, qualifier, - and axis referenced in any test below is declared here. + This fixture bypasses YAML loading. Every base, locus, operator, qualifier, + and axis referenced in the tests below is declared here. """ loci: dict[str, tuple[LocusType, frozenset[LocusRelation]]] = { @@ -129,7 +125,7 @@ def vocabs() -> Vocabularies: "pressure", "temperature", "magnetic_field", - "magnetic_field_magnitude", # intentionally NOT here; test derives via op + "magnetic_field_magnitude", # intentionally absent; derived via operator "magnetic_moment", "plasma_current", "toroidal_field", @@ -163,7 +159,7 @@ def vocabs() -> Vocabularies: def test_parse_simple_locus(vocabs: Vocabularies): - """§A12 row 1: ``elongation_of_plasma_boundary``.""" + """Parse ``elongation_of_plasma_boundary`` with an entity locus.""" name = "elongation_of_plasma_boundary" result = parse(name, vocabs=vocabs) assert isinstance(result, ParseResult) @@ -176,7 +172,7 @@ def test_parse_simple_locus(vocabs: Vocabularies): def test_parse_projection_plus_qualifier_plus_locus(vocabs: Vocabularies): - """§A2 row: radial component, electron qualifier, at plasma_boundary.""" + """Parse a radial component, electron qualifier, and position locus.""" name = "radial_electron_pressure_at_plasma_boundary" result = parse(name, vocabs=vocabs) assert result.ir.projection is not None @@ -190,10 +186,10 @@ def test_parse_projection_plus_qualifier_plus_locus(vocabs: Vocabularies): def test_parse_nested_prefix_operators(vocabs: Vocabularies): - """§A12 row 14 canonical decomposition (maximum + derivative_wrt).""" + """Parse the canonical nested maximum and indexed derivative decomposition.""" name = ( - "maximum_of_derivative_with_respect_to_normalized_poloidal_flux_of_" - "electron_pressure_at_pedestal" + "maximum_of_derivative_of_electron_pressure_at_pedestal_" + "with_respect_to_normalized_poloidal_flux" ) result = parse(name, vocabs=vocabs) assert len(result.ir.operators) == 2 @@ -260,7 +256,7 @@ def test_parse_mechanism(vocabs: Vocabularies): def test_parse_time_derivative_with_locus(vocabs: Vocabularies): """Prefix op + locus — combined round-trip.""" - name = "time_derivative_of_pressure_at_magnetic_axis" + name = "pressure_time_derivative_at_magnetic_axis" result = parse(name, vocabs=vocabs) assert len(result.ir.operators) == 1 assert result.ir.operators[0].op == "time_derivative" @@ -276,12 +272,12 @@ def test_validate_round_trip_helper(vocabs: Vocabularies): # --------------------------------------------------------------------------- -# §A3 disambiguation — row 25 +# Component and locus disambiguation # --------------------------------------------------------------------------- def test_parse_row25_two_of_disambiguation(vocabs: Vocabularies): - """§A12 row 25: two ``_of_`` in one name (component vs locus). + """Distinguish the projection-like suffix from the locus ``_of_``. The postfix-component form ``__component_of_`` is non-canonical; the parser resolves it without ambiguity: @@ -298,8 +294,7 @@ def test_parse_row25_two_of_disambiguation(vocabs: Vocabularies): name = "magnetic_moment_toroidal_component_of_ferritic_element_centroid" # The remainder after stripping locus is # "magnetic_moment_toroidal_component" which is not a valid base; the - # parser must raise rather than silently accept. This is the §A3 - # disambiguation guarantee: the _of_ that binds to the locus is + # parser must raise rather than silently accept. The _of_ that binds to the locus is # consumed exclusively for the locus relation. with pytest.raises(ParseError) as excinfo: parse(name, vocabs=vocabs) @@ -382,7 +377,7 @@ def test_parse_qualifier_plus_base(vocabs: Vocabularies): def test_parse_coordinate_projection_on_carrier(vocabs: Vocabularies): - """``vertical_position_of_flux_loop`` (§A12 row 23 short form).""" + """Parse the short-form ``vertical_position_of_flux_loop`` projection.""" name = "vertical_position_of_flux_loop" result = parse(name, vocabs=vocabs) assert result.ir.projection is not None diff --git a/tests/grammar/test_render_canonical.py b/tests/grammar/test_render_canonical.py index cc43b0b6..b457344d 100644 --- a/tests/grammar/test_render_canonical.py +++ b/tests/grammar/test_render_canonical.py @@ -1,24 +1,28 @@ -"""Canonical rendering test for grammar (plan 38 §A10, item 6). +"""Canonical rendering tests for the grammar. Builds IR instances manually and asserts :func:`imas_standard_names.grammar.render.compose` -produces the exact canonical string described in plan §A2. +produces the exact canonical string required by the grammar. -Canonical form rules (§A2): +Canonical form rules: - Operator wrapping: ``unary_prefix`` → ``_of_``; ``unary_postfix`` → ``_``; ``binary`` → ``_of___`` +- A unary operator over an ordinary base with an ``of``, ``at``, or ``due_to`` + tail renders between the base group and that tail +- Operators inside a binary operand tree retain their natural positions - Projection prefix (canonical): ``_`` before qualifiers+base (component shape) or ``_`` before carrier (coordinate shape) - Locus suffix: ``_of_`` / ``_at_`` / ``_over_`` - Mechanism suffix: ``_due_to_`` -- Order: operators(outer→inner) → projection → qualifiers → base → locus → mechanism +- Default order: operators(outer→inner) → projection → qualifiers → base → locus + → mechanism -Each test is labelled with the §A12 row or §A2 rule it verifies. +Each test states the rendering rule it verifies. """ -from __future__ import annotations - import pytest +from imas_standard_names import parse +from imas_standard_names.grammar import parse_standard_name from imas_standard_names.grammar.ir import ( AxisProjection, BaseKind, @@ -36,12 +40,12 @@ from imas_standard_names.grammar.render import RenderError, compose # --------------------------------------------------------------------------- -# §A2 rule 1: bare base (no operators, no decorators) +# Bare base with no operators or decorators # --------------------------------------------------------------------------- def test_render_bare_quantity_base() -> None: - """§A2: bare quantity base → its token unchanged.""" + """A bare quantity base renders as its token unchanged.""" ir = StandardNameIR( base=QuantityOrCarrier(token="pressure", kind=BaseKind.QUANTITY) ) @@ -49,7 +53,7 @@ def test_render_bare_quantity_base() -> None: def test_render_bare_geometry_carrier() -> None: - """§A2: bare geometry carrier → its token unchanged.""" + """A bare geometry carrier renders as its token unchanged.""" ir = StandardNameIR( base=QuantityOrCarrier(token="normalized_minor_radius", kind=BaseKind.GEOMETRY) ) @@ -57,12 +61,12 @@ def test_render_bare_geometry_carrier() -> None: # --------------------------------------------------------------------------- -# §A2 rule 2: unary prefix operator +# Unary prefix operator # --------------------------------------------------------------------------- def test_render_unary_prefix_operator() -> None: - """§A2: ``unary_prefix`` → ``_of_``.""" + """Render ``unary_prefix`` as ``_of_``.""" ir = StandardNameIR( operators=[OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op="maximum")], base=QuantityOrCarrier(token="pressure", kind=BaseKind.QUANTITY), @@ -71,7 +75,7 @@ def test_render_unary_prefix_operator() -> None: def test_render_unary_prefix_derivative() -> None: - """§A2: ``derivative_of_X`` rendering.""" + """Render a derivative prefix around its operand.""" ir = StandardNameIR( operators=[ OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op="derivative") @@ -95,12 +99,12 @@ def test_render_unary_prefix_flux_surface_averaged() -> None: # --------------------------------------------------------------------------- -# §A2 rule 3: unary postfix operator +# Unary postfix operator # --------------------------------------------------------------------------- def test_render_unary_postfix_magnitude() -> None: - """§A2: ``unary_postfix`` → ``_``.""" + """Render ``unary_postfix`` as ``_``.""" ir = StandardNameIR( operators=[ OperatorApplication(kind=OperatorKind.UNARY_POSTFIX, op="magnitude") @@ -111,7 +115,7 @@ def test_render_unary_postfix_magnitude() -> None: def test_render_unary_postfix_gyroaveraged() -> None: - """``gyroaveraged`` postfix operator (§A12 row 15 fragment).""" + """Render the ``gyroaveraged`` postfix operator.""" ir = StandardNameIR( operators=[ OperatorApplication(kind=OperatorKind.UNARY_POSTFIX, op="gyroaveraged") @@ -131,12 +135,12 @@ def test_render_unary_postfix_moment() -> None: # --------------------------------------------------------------------------- -# §A2 rule 4: binary operators +# Binary operators # --------------------------------------------------------------------------- def test_render_binary_ratio() -> None: - """§A2 binary: ``ratio_of_A_to_B``.""" + """Render a binary ratio with the ``to`` separator.""" ir = StandardNameIR( operators=[ OperatorApplication( @@ -161,7 +165,7 @@ def test_render_binary_ratio() -> None: def test_render_binary_product() -> None: - """§A2 binary: ``product_of_A_and_B``.""" + """Render a binary product with the ``and`` separator.""" ir = StandardNameIR( operators=[ OperatorApplication( @@ -186,12 +190,12 @@ def test_render_binary_product() -> None: # --------------------------------------------------------------------------- -# §A2 rule 5: axis projection prefix (canonical form) +# Axis projection prefix # --------------------------------------------------------------------------- def test_render_projection_component_prefix() -> None: - """§A2: component projection → ``_``.""" + """Render a component projection as ``_``.""" ir = StandardNameIR( projection=AxisProjection(axis="radial", shape=ProjectionShape.COMPONENT), base=QuantityOrCarrier(token="pressure", kind=BaseKind.QUANTITY), @@ -200,7 +204,7 @@ def test_render_projection_component_prefix() -> None: def test_render_projection_toroidal_component() -> None: - """Toroidal component projection (§A12 row 24).""" + """Render a toroidal component projection.""" ir = StandardNameIR( projection=AxisProjection(axis="toroidal", shape=ProjectionShape.COMPONENT), base=QuantityOrCarrier(token="magnetic_field", kind=BaseKind.QUANTITY), @@ -209,7 +213,7 @@ def test_render_projection_toroidal_component() -> None: def test_render_projection_coordinate_prefix() -> None: - """§A2: coordinate projection → short form ``_``.""" + """Render a coordinate projection as short form ``_``.""" ir = StandardNameIR( projection=AxisProjection(axis="vertical", shape=ProjectionShape.COORDINATE), base=QuantityOrCarrier(token="normalized_minor_radius", kind=BaseKind.GEOMETRY), @@ -218,7 +222,7 @@ def test_render_projection_coordinate_prefix() -> None: def test_render_projection_normalized_toroidal_coordinate() -> None: - """§A12 row 17: ``normalized_toroidal_flux_coordinate`` with axis projection.""" + """Render ``normalized_toroidal_flux_coordinate`` with an axis projection.""" ir = StandardNameIR( projection=AxisProjection( axis="normalized_toroidal", shape=ProjectionShape.COORDINATE @@ -233,12 +237,12 @@ def test_render_projection_normalized_toroidal_coordinate() -> None: # --------------------------------------------------------------------------- -# §A2 rule 6: locus suffix +# Locus suffix # --------------------------------------------------------------------------- def test_render_locus_of_entity() -> None: - """§A12 row 1: ``elongation_of_plasma_boundary`` — entity locus with _of_.""" + """Render ``elongation_of_plasma_boundary`` with an entity locus.""" ir = StandardNameIR( base=QuantityOrCarrier(token="elongation", kind=BaseKind.QUANTITY), locus=LocusRef( @@ -264,7 +268,7 @@ def test_render_locus_at_position() -> None: def test_render_locus_of_position() -> None: - """§A12 row 3: position locus with ``_of_`` relation.""" + """Render a position locus with the ``_of_`` relation.""" ir = StandardNameIR( base=QuantityOrCarrier(token="major_radius", kind=BaseKind.QUANTITY), locus=LocusRef( @@ -277,7 +281,7 @@ def test_render_locus_of_position() -> None: def test_render_locus_entity_of() -> None: - """Entity locus uses ``_of_`` relation (§A5).""" + """Render an entity locus with the ``_of_`` relation.""" ir = StandardNameIR( base=QuantityOrCarrier(token="pressure", kind=BaseKind.QUANTITY), locus=LocusRef( @@ -290,7 +294,7 @@ def test_render_locus_entity_of() -> None: # --------------------------------------------------------------------------- -# §A2 rule 7: mechanism suffix +# Mechanism suffix # --------------------------------------------------------------------------- @@ -318,12 +322,12 @@ def test_render_mechanism_with_locus() -> None: # --------------------------------------------------------------------------- -# §A2 combined: operator + projection + locus + mechanism ordering +# Combined operator, projection, locus, and mechanism ordering # --------------------------------------------------------------------------- -def test_render_full_a2_example() -> None: - """§A2 canonical template: ``_of____``.""" +def test_render_operator_between_projected_base_and_locus() -> None: + """Render ``____`` canonically.""" ir = StandardNameIR( operators=[ OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op="root_mean_square") @@ -338,12 +342,12 @@ def test_render_full_a2_example() -> None: ), ) assert compose(ir) == ( - "root_mean_square_of_radial_electron_pressure_at_plasma_boundary" + "radial_electron_pressure_root_mean_square_at_plasma_boundary" ) def test_render_nested_operators_outer_first() -> None: - """§A2: outer operator applied first → outermost op prefix appears leftmost.""" + """Render the outermost operator nearest the trailing locus.""" ir = StandardNameIR( operators=[ OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op="maximum"), @@ -356,12 +360,12 @@ def test_render_nested_operators_outer_first() -> None: type=LocusType.POSITION, ), ) - # maximum is outer → leftmost; derivative is inner → immediately around base - assert compose(ir) == "maximum_of_derivative_of_pressure_at_pedestal" + # The derivative is inner and nearest the base; maximum remains outer. + assert compose(ir) == "pressure_derivative_maximum_at_pedestal" -def test_render_a12_row_14_nested_operators() -> None: - """§A12 row 14 exact example: maximum_of_derivative_of_... at pedestal.""" +def test_render_nested_indexed_operator_at_pedestal() -> None: + """Render a nested maximum and indexed derivative at the pedestal.""" ir = StandardNameIR( operators=[ OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op="maximum"), @@ -379,27 +383,37 @@ def test_render_a12_row_14_nested_operators() -> None: ), ) assert compose(ir) == ( - "maximum_of_derivative_with_respect_to_normalized_poloidal_flux_of_" - "electron_pressure_at_pedestal" + "maximum_of_derivative_of_electron_pressure_at_pedestal_" + "with_respect_to_normalized_poloidal_flux" ) -def test_render_a12_row_22_maximum_power_flux_density() -> None: - """§A12 row 22: ``maximum_of_power_flux_density_at_inner_divertor_target``.""" +def test_render_maximum_energy_flux_at_target() -> None: + """Render the peak deposited-energy surface load at a divertor target.""" ir = StandardNameIR( operators=[OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op="maximum")], - base=QuantityOrCarrier(token="power_flux_density", kind=BaseKind.QUANTITY), + qualifiers=[Qualifier(token="energy")], + base=QuantityOrCarrier(token="flux", kind=BaseKind.QUANTITY), locus=LocusRef( relation=LocusRelation.AT, token="inner_divertor_target", type=LocusType.POSITION, ), ) - assert compose(ir) == "maximum_of_power_flux_density_at_inner_divertor_target" + rendered = compose(ir) + assert rendered == "energy_flux_maximum_at_inner_divertor_target" + + parsed = parse(rendered, strict=True) + assert compose(parsed.ir) == rendered + + flat = parse_standard_name(rendered) + assert flat.physical_base == "flux" + assert flat.channel is not None + assert flat.channel.value == "energy" # --------------------------------------------------------------------------- -# §A2 qualifier rendering +# Qualifier rendering # --------------------------------------------------------------------------- diff --git a/tests/grammar/test_tail_gated_operator_order.py b/tests/grammar/test_tail_gated_operator_order.py new file mode 100644 index 00000000..22b7e677 --- /dev/null +++ b/tests/grammar/test_tail_gated_operator_order.py @@ -0,0 +1,122 @@ +"""Canonical operator placement around optional locus tails.""" + +import pytest + +from imas_standard_names.grammar.parser import parse +from imas_standard_names.grammar.render import compose + + +@pytest.mark.parametrize( + ("current", "canonical"), + [ + ( + "root_mean_square_of_wave_current_of_antenna_strap", + "wave_current_root_mean_square_of_antenna_strap", + ), + ( + "voltage_of_ion_cyclotron_heating_antenna_amplitude", + "voltage_amplitude_of_ion_cyclotron_heating_antenna", + ), + ( + "time_derivative_of_pressure_at_magnetic_axis", + "pressure_time_derivative_at_magnetic_axis", + ), + ( + "maximum_of_energy_flux_at_first_wall", + "energy_flux_maximum_at_first_wall", + ), + ( + "maximum_of_energy_flux_at_divertor_target", + "energy_flux_maximum_at_divertor_target", + ), + ( + "minimum_of_energy_flux_at_first_wall", + "energy_flux_minimum_at_first_wall", + ), + ( + "maximum_of_heat_flux_at_divertor_target", + "heat_flux_maximum_at_divertor_target", + ), + ( + "maximum_of_particle_flux_at_first_wall", + "particle_flux_maximum_at_first_wall", + ), + ( + "flux_surface_averaged_electron_density_at_plasma_boundary", + "electron_density_flux_surface_averaged_at_plasma_boundary", + ), + ( + "flux_surface_averaged_electron_temperature_at_plasma_boundary", + "electron_temperature_flux_surface_averaged_at_plasma_boundary", + ), + ( + "toroidal_flux_surface_averaged_argon_velocity_at_plasma_boundary", + "toroidal_argon_velocity_flux_surface_averaged_at_plasma_boundary", + ), + ( + "maximum_of_volume_averaged_electron_density_at_magnetic_axis", + "electron_density_volume_averaged_maximum_at_magnetic_axis", + ), + ( + "magnetic_field_of_iron_core_segment_magnitude", + "magnetic_field_magnitude_of_iron_core_segment", + ), + ( + "velocity_due_to_pellet_injection_magnitude", + "velocity_magnitude_due_to_pellet_injection", + ), + ( + "maximum_magnetic_field_of_flux_surface_magnitude", + "maximum_magnetic_field_magnitude_of_flux_surface", + ), + ( + "maximum_of_inverse_of_electron_density_due_to_non_inductive_current_drive", + "electron_density_inverse_maximum_due_to_non_inductive_current_drive", + ), + ( + "maximum_of_inverse_of_electron_density_at_magnetic_axis", + "electron_density_inverse_maximum_at_magnetic_axis", + ), + ( + "root_mean_square_of_radial_electron_pressure_at_plasma_boundary", + "radial_electron_pressure_root_mean_square_at_plasma_boundary", + ), + ( + "maximum_of_derivative_of_pressure_at_pedestal", + "pressure_derivative_maximum_at_pedestal", + ), + ], +) +def test_tailed_operator_moves_between_base_and_locus( + current: str, canonical: str +) -> None: + ir = parse(current).ir + + assert compose(ir) == canonical + assert parse(canonical, strict=True).ir == ir + + +@pytest.mark.parametrize( + "name", + [ + "accumulated_ammonia_count", + ( + "flux_surface_averaged_ratio_of_square_of_" + "toroidal_flux_coordinate_gradient_magnitude_" + "to_square_of_major_radius" + ), + ], +) +def test_operator_without_repositioning_tail_keeps_natural_reading(name: str) -> None: + ir = parse(name, strict=True).ir + + assert compose(ir) == name + assert parse(compose(ir), strict=True).ir == ir + + +def test_operator_in_composite_operand_keeps_natural_reading() -> None: + name = "ratio_of_pressure_to_square_of_temperature_at_magnetic_axis" + ir = parse(name, strict=True).ir + + assert compose(ir) == name + assert parse(compose(ir), strict=True).ir == ir diff --git a/tests/test_physical_base_kinds.py b/tests/test_physical_base_kinds.py index f6050d10..98d8fe05 100644 --- a/tests/test_physical_base_kinds.py +++ b/tests/test_physical_base_kinds.py @@ -1,33 +1,219 @@ -import hashlib +import pytest from imas_standard_names.grammar.vocab_loaders import load_physical_bases _VECTOR_CORRECTIONS = {"momentum", "torque", "torque_density"} -_BASELINE_UNCHANGED_KIND_DIGEST = ( - "6062e5c22be7788220b390f09a5c8c1f1037ece0f8bc4422b2e6a3e3b1763956" -) +_BASELINE_KINDS = { + "absorptivity": "scalar", + "acceleration": "vector", + "angle": "scalar", + "angular_frequency": "scalar", + "angular_momentum": "scalar", + "angular_velocity": "vector", + "angular_width": "scalar", + "area": "scalar", + "aspect_ratio": "scalar", + "atomic_number": "scalar", + "bandwidth": "scalar", + "beta": "scalar", + "brightness": "scalar", + "capacitance": "scalar", + "center_of_mass_velocity": "vector", + "charge": "scalar", + "charge_number": "scalar", + "coefficient": "scalar", + "collisionality": "scalar", + "conductivity": "scalar", + "confinement_enhancement_factor": "scalar", + "confinement_time": "scalar", + "connection_length": "scalar", + "convection_velocity": "vector", + "coulomb_logarithm": "scalar", + "count": "scalar", + "current": "scalar", + "current_density": "vector", + "curvature": "scalar", + "damping_rate": "scalar", + "dead_time": "scalar", + "debye_length": "scalar", + "decay_length": "scalar", + "decay_time": "scalar", + "delay": "scalar", + "density": "scalar", + "diameter": "scalar", + "diffusion_coefficient": "scalar", + "diffusivity": "scalar", + "distance": "scalar", + "distribution": "scalar", + "duration": "scalar", + "efficiency": "scalar", + "electric_field": "vector", + "electrical_conductivity": "scalar", + "electrostatic_potential": "scalar", + "ellipticity": "scalar", + "elongation": "scalar", + "emissivity": "scalar", + "energy": "scalar", + "etendue": "scalar", + "exposure_time": "scalar", + "factor": "scalar", + "fall_time": "scalar", + "field_current_coupling_coefficient": "scalar", + "field_strength": "scalar", + "flag": "scalar", + "flow": "scalar", + "fluence": "scalar", + "flux": "scalar", + "flux_limiter_coefficient": "scalar", + "flux_surface_averaged_metric": "scalar", + "focal_length": "scalar", + "force": "vector", + "fraction": "scalar", + "frequency": "scalar", + "fusion_gain": "scalar", + "gap": "scalar", + "grating_line_density": "scalar", + "growth_rate": "scalar", + "half_width": "scalar", + "heating_power": "scalar", + "height": "scalar", + "impedance": "scalar", + "index": "scalar", + "inductance": "scalar", + "instrument_function": "scalar", + "intensity": "scalar", + "internal_energy": "scalar", + "internal_inductance": "scalar", + "ionisation_potential": "scalar", + "kinetic_energy": "scalar", + "kurtosis": "scalar", + "larmor_radius": "scalar", + "length": "scalar", + "li": "scalar", + "loop_voltage": "scalar", + "mach_number": "scalar", + "magnetic_field": "vector", + "magnetic_flux": "scalar", + "magnetic_moment": "vector", + "magnetic_pressure": "scalar", + "magnetic_shear": "scalar", + "magnetic_vector_potential": "vector", + "magnetization": "scalar", + "mass": "scalar", + "mass_density": "scalar", + "mass_flow_rate": "scalar", + "mass_number": "scalar", + "metric_tensor": "tensor", + "mode_number": "scalar", + "momentum": "vector", + "multiplicity": "scalar", + "number_density": "scalar", + "opacity": "scalar", + "orbit_integral": "scalar", + "parameter": "scalar", + "parity": "scalar", + "peaking_factor": "scalar", + "period": "scalar", + "permeability": "scalar", + "phase": "scalar", + "photon_energy": "scalar", + "photon_radiance": "scalar", + "pitch_angle": "scalar", + "polarization_vector": "vector", + "poloidal_current_function": "scalar", + "potential": "scalar", + "power": "scalar", + "power_density": "scalar", + "pressure": "scalar", + "probability": "scalar", + "psi_star": "scalar", + "q_profile": "scalar", + "radiance": "scalar", + "radius": "scalar", + "rate": "scalar", + "reflectivity": "scalar", + "refractive_index": "scalar", + "relative_humidity": "scalar", + "residual": "scalar", + "resistance": "scalar", + "resistive_diffusion_time": "scalar", + "resistivity": "scalar", + "response_function": "scalar", + "rise_time": "scalar", + "rotation_frequency": "scalar", + "rotational_transform": "scalar", + "roughness": "scalar", + "safety_factor": "scalar", + "sensitivity": "scalar", + "shearing_rate": "scalar", + "signal_to_noise_ratio": "scalar", + "size": "scalar", + "skewness": "scalar", + "solid_angle": "scalar", + "source": "scalar", + "source_rate": "scalar", + "speed": "scalar", + "squareness": "scalar", + "stokes_parameter": "scalar", + "stokes_vector": "vector", + "strain": "tensor", + "temperature": "scalar", + "thickness": "scalar", + "tilt_angle": "scalar", + "time": "scalar", + "toroidal_flux_coordinate_gradient": "vector", + "torque": "vector", + "torque_density": "vector", + "transit_time": "scalar", + "transmissivity": "scalar", + "triangularity": "scalar", + "tritium_breeding_ratio": "scalar", + "turn_count": "scalar", + "turns_per_metre": "scalar", + "vector_potential": "vector", + "velocity": "vector", + "verdet_constant": "scalar", + "vibrational_level": "scalar", + "voltage": "scalar", + "volume": "scalar", + "vorticity": "scalar", + "wave_vector": "vector", + "wavelength": "scalar", + "weight": "scalar", + "width": "scalar", +} -def _kind_map_digest(kind_map: dict[str, str]) -> str: - payload = "".join( - f"{name}={kind}\n" - for name, kind in sorted(kind_map.items()) - if name not in _VECTOR_CORRECTIONS - ) - return hashlib.sha256(payload.encode()).hexdigest() - - -def test_only_momentum_and_torque_bases_change_to_vector() -> None: - kinds = { +def _load_kinds() -> dict[str, str]: + return { name: definition.kind for name, definition in load_physical_bases().bases.items() } + +def _assert_baseline_kinds(kinds: dict[str, str]) -> None: + assert {name: kinds[name] for name in _BASELINE_KINDS} == _BASELINE_KINDS + + +def test_only_momentum_and_torque_bases_change_to_vector() -> None: + kinds = _load_kinds() + assert {name: kinds[name] for name in _VECTOR_CORRECTIONS} == dict.fromkeys( _VECTOR_CORRECTIONS, "vector" ) - assert _kind_map_digest(kinds) == _BASELINE_UNCHANGED_KIND_DIGEST + _assert_baseline_kinds(kinds) + + +def test_baseline_kind_contract_rejects_changed_existing_base() -> None: + kinds = _load_kinds() + kinds["acceleration"] = "scalar" + + with pytest.raises(AssertionError): + _assert_baseline_kinds(kinds) + + +def test_baseline_kind_contract_accepts_added_base() -> None: + kinds = _load_kinds() + kinds["synthetic_base"] = "scalar" - baseline_kinds = kinds | dict.fromkeys(_VECTOR_CORRECTIONS, "scalar") - changed = {name for name, kind in kinds.items() if kind != baseline_kinds.get(name)} - assert changed == _VECTOR_CORRECTIONS + _assert_baseline_kinds(kinds) diff --git a/tests/test_vocabulary_gap_tokens.py b/tests/test_vocabulary_gap_tokens.py new file mode 100644 index 00000000..4f2c8641 --- /dev/null +++ b/tests/test_vocabulary_gap_tokens.py @@ -0,0 +1,120 @@ +"""Closed-vocabulary coverage for DD-backed quantity families. + +Two accepted additions are intentionally additive, while the strain-gauge +candidate is rejected on positive evidence: + +* Rejected: ``measurement_direction_unit_vector``. Accepted alternatives: + ``first_measurement_direction_unit_vector`` for ``sensor/direction/{x,y,z}`` + and ``second_measurement_direction_unit_vector`` for + ``sensor/direction_second/{x,y,z}``. The plain path is the primary member of + the pair because the companion is explicitly ``direction_second``. Collapsing + the two would erase which distinct strain-rosette measurement axis a vector + describes. Generic ``direction_unit_vector`` names any device direction and + carries no strain-rosette measurement-axis role; the first and second carriers + identify the two ordered measurement axes. The six leaves report DD unit + ``m`` despite their parent documentation defining a unit vector. That is a DD + unit defect; it does not turn an orientation carrier into a dimensional + physical quantity. +* ``s0`` through ``s3`` project the existing ``stokes_parameter`` base. The + eight FOCS leaves explicitly document the four indices as initial and output + Stokes-vector components; Stokes coordinates are dimensionless (unit ``1``). +* ``relative_humidity`` is the dimensionless fraction of humidity from zero to + one measured at the X-ray detector, as documented for + ``camera_x_rays/detector_humidity`` (unit ``1``). +The shipped catalog snapshot contains none of the representative identities for +the two accepted additions or an equivalent token in those families. +Unindexed ``stokes_parameter`` erases which polarization coordinate is stored; +generic fractions do not say relative humidity. +""" + +from dataclasses import replace + +import pytest + +from imas_standard_names import ParseError, compose, parse +from imas_standard_names.grammar.parser import load_default_vocabularies + + +@pytest.mark.parametrize( + ("registry", "tokens"), + [ + ("component_axes", {"s0", "s1", "s2", "s3"}), + ("bases", {"relative_humidity"}), + ], +) +def test_dd_backed_tokens_are_registered(registry: str, tokens: set[str]) -> None: + vocabularies = load_default_vocabularies() + assert tokens <= getattr(vocabularies, registry) + + +@pytest.mark.parametrize( + "name", + [ + "s0_stokes_parameter_of_fiber_optic_current_sensor", + "s1_stokes_parameter_of_fiber_optic_current_sensor", + "s2_stokes_parameter_of_fiber_optic_current_sensor", + "s3_stokes_parameter_of_fiber_optic_current_sensor", + "relative_humidity_of_detector", + ], +) +def test_dd_backed_vocabulary_name_round_trips_through_render(name: str) -> None: + result = parse(name, strict=True) + assert compose(result.ir) == name + + +@pytest.mark.parametrize( + ("registry", "token", "name"), + [ + ( + "component_axes", + "s0", + "s0_stokes_parameter_of_fiber_optic_current_sensor", + ), + ( + "component_axes", + "s1", + "s1_stokes_parameter_of_fiber_optic_current_sensor", + ), + ( + "component_axes", + "s2", + "s2_stokes_parameter_of_fiber_optic_current_sensor", + ), + ( + "component_axes", + "s3", + "s3_stokes_parameter_of_fiber_optic_current_sensor", + ), + ("bases", "relative_humidity", "relative_humidity_of_detector"), + ], +) +def test_dd_backed_name_fails_when_its_token_is_removed( + registry: str, token: str, name: str +) -> None: + vocabularies = load_default_vocabularies() + reduced_tokens = getattr(vocabularies, registry) - {token} + reduced_vocabularies = replace(vocabularies, **{registry: reduced_tokens}) + + with pytest.raises(ParseError): + parse(name, vocabs=reduced_vocabularies, strict=True) + + +@pytest.mark.parametrize( + "name", + [ + "x_first_measurement_direction_unit_vector_of_strain_gauge", + "y_first_measurement_direction_unit_vector_of_strain_gauge", + "z_first_measurement_direction_unit_vector_of_strain_gauge", + "x_second_measurement_direction_unit_vector_of_strain_gauge", + "y_second_measurement_direction_unit_vector_of_strain_gauge", + "z_second_measurement_direction_unit_vector_of_strain_gauge", + ], +) +def test_strain_gauge_uses_the_existing_ordered_carrier_pair(name: str) -> None: + result = parse(name, strict=True) + assert compose(result.ir) == name + + +def test_nonordinal_strain_gauge_carrier_is_rejected() -> None: + with pytest.raises(ParseError): + parse("x_measurement_direction_unit_vector_of_strain_gauge", strict=True)