Skip to content

coverage

Read an ODD's own decisions back against the ODD source: which models fired, which can never fire, and which elements produced no output at all. See the ODD coverage guide for what each finding means; the CLI wraps analyze and renders CoverageReport.to_dict().

opm.coverage

What an ODD declares, and what of it actually runs.

-t json records every decision the processing model made on one document. Coverage rolls those records up across a corpus and reads them against the ODD itself, to answer the two questions an ODD author keeps asking:

  • What did I write that never runs? Models that never fired, models that cannot fire because an earlier sibling has no @predicate, and elementSpecs for elements the corpus never contains.
  • What is in my documents that I never handled? Elements no model matched at all (unmatched records), and elements whose spec exists but whose every predicate was false — those produce no record whatsoever, which is exactly why they are hard to notice by hand.

Models inherited from an extended ODD are reported separately and never counted as the author's problem: a local elementSpec replaces the inherited one wholesale, so an inherited model is only changeable by redeclaring the element. The locality split comes from opm.odd_compiler.parse_odd.spec_origin, surfaced in the compiled ODD_MODELS table as source.

Coverage transforms whole documents; it ignores [chunking], since a chunk selector may not cover the document and coverage is about the ODD, not the site.

Occurrence dataclass

Occurrence(
    element: str,
    count: int = 0,
    xpath: str | None = None,
    file: str | None = None,
    line: int | None = None,
)

Where an element first turned up, and how often it did.

location property

location: str

file:line when positions are available, else the XPath.

ModelInfo dataclass

ModelInfo(
    key: str,
    element: str,
    behaviour: str | None = None,
    predicate: str | None = None,
    desc: str | None = None,
    output: str | None = None,
    source: str | None = None,
    template: bool = False,
    hits: int = 0,
    unreachable: str | None = None,
)

One ODD_MODELS entry plus the verdict on it.

emits_records property

emits_records: bool

Whether firing this model would be visible in the JSON records.

A model with neither @behaviour nor a pb:template compiles to a bare recursion into the children: it produces no record, so a zero hit count says nothing about whether it ran.

CoverageReport dataclass

CoverageReport(
    odd: Path,
    channel: str,
    documents: list[Path] = list(),
    models: dict[str, ModelInfo] = dict(),
    behaviours: Counter = Counter(),
    elements_seen: Counter = Counter(),
    records: int = 0,
    suppressed: int = 0,
    unmatched: dict[str, Occurrence] = dict(),
    dropped: dict[str, Occurrence] = dict(),
    unused_specs: list[dict] = list(),
    attribute_only_specs: list[str] = list(),
    unsupported: list[dict] = list(),
)

The full diagnostic picture of one ODD against one corpus.

unused_models

unused_models() -> list[ModelInfo]

Local models that could have fired on this corpus and did not.

Restricted to models whose element the corpus actually contains: a model for an element that never appears says nothing about the model, and those elements are already listed as unexercised specs. Unreachable models are excluded too — they are reported on their own, with the reason, and listing them twice would only pad the actionable list.

Source code in src/opm/coverage.py
def unused_models(self) -> list[ModelInfo]:
    """Local models that could have fired on this corpus and did not.

    Restricted to models whose element the corpus actually contains: a model
    for an element that never appears says nothing about the model, and
    those elements are already listed as unexercised specs. Unreachable
    models are excluded too — they are reported on their own, with the
    reason, and listing them twice would only pad the actionable list.
    """
    return sorted(
        (
            m for m in self.models.values()
            if m.local and m.hits == 0 and m.emits_records
            and not m.unreachable and self.elements_seen.get(m.element)
        ),
        key=lambda m: (m.element, m.key),
    )

silent_models

silent_models() -> list[ModelInfo]

Local models that emit nothing: no @behaviour, no pb:template.

Source code in src/opm/coverage.py
def silent_models(self) -> list[ModelInfo]:
    """Local models that emit nothing: no ``@behaviour``, no ``pb:template``."""
    return sorted(
        (m for m in self.models.values() if m.local and not m.emits_records),
        key=lambda m: (m.element, m.key),
    )

iter_element_paths

iter_element_paths(root)

Yield (element, xpath) for the whole tree, top-down.

The paths are the ones -t json records (opm.runtime.json_output_functions._element_path), which is what makes "this element produced no record" answerable by set difference. They are built once on the way down rather than reconstructed per element: the bottom-up version rescans the siblings at every step, which turns a big document into a quadratic walk.

Source code in src/opm/coverage.py
def iter_element_paths(root):
    """Yield ``(element, xpath)`` for the whole tree, top-down.

    The paths are the ones ``-t json`` records
    (`opm.runtime.json_output_functions._element_path`), which is what
    makes "this element produced no record" answerable by set difference. They
    are built once on the way down rather than reconstructed per element: the
    bottom-up version rescans the siblings at every step, which turns a big
    document into a quadratic walk.
    """
    if not isinstance(root.tag, str):
        return
    default_ns = etree.QName(root).namespace
    root_step = (
        _element_name(root) if etree.QName(root).namespace == default_ns else '*'
    )
    yield from _descend(root, f'/{root_step}', default_ns)

unreachable_models

unreachable_models(
    parsed, output_mode: str = "web"
) -> dict[str, str]

Models that can never fire, mapped to the reason why.

Mirrors the dispatch the code generator emits: conditional models become an if/elif chain in document order and the first model without a predicate becomes the else. Two consequences, both silent in the ODD: when the very first model has no predicate the rest of the spec is dropped on the floor, and any further unconditional model after the first can never be the fallback.

Source code in src/opm/coverage.py
def unreachable_models(parsed, output_mode: str = 'web') -> dict[str, str]:
    """Models that can never fire, mapped to the reason why.

    Mirrors the dispatch the code generator emits: conditional models become an
    ``if``/``elif`` chain in document order and the *first* model without a
    predicate becomes the ``else``. Two consequences, both silent in the ODD:
    when the very first model has no predicate the rest of the spec is dropped
    on the floor, and any further unconditional model after the first can never
    be the fallback.
    """
    findings: dict[str, str] = {}
    for spec in iter_element_specs(parsed):
        ident = spec.get('ident')
        if not ident or ident in ('*', 'text()'):
            continue
        _scan_level(
            _top_level_models(spec, output_mode), ident, spec, output_mode,
            findings, dead=None,
        )
    return findings

analyze

analyze(
    paths,
    *,
    cfg=None,
    odd: Path | None = None,
    output_mode: str = "json",
    parameters: dict | None = None,
    base_css: str | None = None,
) -> CoverageReport

Run paths through the ODD in JSON mode and report on the outcome.

output_mode is a JSON mode (json, json-typst, …); the channel it inspects decides which @output-tagged models participate, so a coverage run is always about one channel. base_css is passed on to the compiler (see resolve_transform_module).

Source code in src/opm/coverage.py
def analyze(
    paths,
    *,
    cfg=None,
    odd: Path | None = None,
    output_mode: str = 'json',
    parameters: dict | None = None,
    base_css: str | None = None,
) -> CoverageReport:
    """Run *paths* through the ODD in JSON mode and report on the outcome.

    *output_mode* is a JSON mode (``json``, ``json-typst``, …); the channel it
    inspects decides which ``@output``-tagged models participate, so a coverage
    run is always about one channel. *base_css* is passed on to the compiler
    (see [`resolve_transform_module`][opm.odd_cache.resolve_transform_module]).
    """
    from opm.config import ProjectConfig
    from opm.odd_cache import resolve_transform_module
    from opm.transform import (
        load_project_documents,
        load_transform_module,
        project_xpath_env,
    )

    documents = [Path(p) for p in paths]
    resolved_odd = odd if odd is not None else (cfg.odd_for_type('json') if cfg else None)
    resolved = resolve_transform_module(
        odd=resolved_odd,
        output_mode=output_mode,
        use_packaged_default=resolved_odd is None,
        base_css=base_css,
    )
    module = load_transform_module(resolved.module_path)
    odd_path = Path(resolved.source_odd) if resolved.source_odd else Path('(module)')
    channel = mode_named(output_mode).channel

    report = CoverageReport(
        odd=odd_path,
        channel=channel,
        documents=documents,
        models={
            key: ModelInfo(
                key=key,
                element=entry.get('element', '?'),
                behaviour=entry.get('behaviour'),
                predicate=entry.get('predicate'),
                desc=entry.get('desc'),
                output=entry.get('output'),
                source=entry.get('source'),
                template=bool(entry.get('template')),
            )
            for key, entry in getattr(module, 'ODD_MODELS', {}).items()
        },
        unsupported=list(getattr(module, 'ODD_UNSUPPORTED', [])),
    )

    parsed = load_odd(odd_path) if resolved.source_odd else None
    if parsed is not None:
        for key, reason in unreachable_models(parsed, channel).items():
            info = report.models.get(key)
            if info is not None:
                info.unreachable = reason

    merged = dict(cfg.parameters) if cfg is not None else {}
    merged.update(parameters or {})

    # A predicate is free to call doc(), collection() or a tp: extension
    # function; without the same runtime context the transform gets, those
    # predicates would fail here and their models be reported as never fired.
    # The registers are parsed once; each document gets its own environment.
    project = cfg if cfg is not None else ProjectConfig()
    registers = load_project_documents(project)

    for document in documents:
        root = etree.parse(str(document)).getroot()
        env = project_xpath_env(project, document, documents=registers)
        payload = json.loads(module.transform(root, dict(merged) or None, xpath_env=env)[0])
        seen = _Seen()
        _scan_records(payload.get('document', []), report, seen)
        _scan_source(root, document, seen, report)

    if parsed is not None:
        _report_specs(parsed, report)
    return report