From 11875664c6962d3cd01aa33f288b3461c3faf517 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 30 Oct 2026 16:41:24 -0500 Subject: [PATCH] Add an optional declaration to AnalyticFunctionSpec (ISSUE-170) An analytic function can now say what its results mean: result kind, measures with unit, as-of and basis, and the four absence sentences. It is the minimum of trellis-analytics' Declaration, with the same field names, so trellis declarations validate here (all 42 in Resource Explorer's analysis catalog do). Optional; existing entries unchanged. Claude-Session: https://claude.ai/code/session_015pdMuPkqttD1yKAKJLS9Fc Signed-off-by: Dan Wolfson --- PYEGERIA_ISSUES.md | 15 +++ pyegeria/view/analytic_registry.py | 101 +++++++++++++++++- .../test_issue_170_analytic_declaration.py | 75 +++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 tests/micro-tests/test_issue_170_analytic_declaration.py diff --git a/PYEGERIA_ISSUES.md b/PYEGERIA_ISSUES.md index 7d765d1d..7b9c7e34 100644 --- a/PYEGERIA_ISSUES.md +++ b/PYEGERIA_ISSUES.md @@ -172,6 +172,7 @@ re-test candidates moved into *Awaiting re-test* below the open sections. create-from-template) and 7 (`viewElementGUID`) are still open. **Open pyegeria items** +- ISSUE-170: `AnalyticFunctionSpec` can't say what its results mean (unit, as-of, basis, absence sentences); added an optional `declaration` - ISSUE-168: pyegeria can't run on Python 3.14: `nest_asyncio`, applied at import time, makes `asyncio.current_task()` return None - ISSUE-155: pyegeria gaps against Egeria `main` since 6.2 (omvs_audit + source diff, V6.2..05a5e982) - ISSUE-150: Dr.Egeria commands for an Analytic Function's Egeria declaration: a GovernanceActionType that Resource Explorer runs as an engine host, plus a synchronous path that bypasses Egeria @@ -193,6 +194,20 @@ into the entry now, so it isn't rediscovered from scratch later. --- +### ISSUE-170: `AnalyticFunctionSpec` can't say what its results mean (unit, as-of, basis, absence sentences); added an optional `declaration` + +**Layer:** pyegeria · **Status:** in PR (branch `feat/analytic-declaration`) · **Found:** 2026-10-30, Resource Explorer's portal-extensibility evaluation, §3 item 1 (trellis `docs/design-notes/EVAL-PORTAL-EXTENSIBILITY-VS-RE-2026-10-30.md`). + +**What:** trellis-analytics' `Declaration` lets a step say what its results are: result kind, each measure's unit, as-of and basis, and a sentence for each way a result can be absent. Resource Explorer renders from that declaration with no per-analysis code. pyegeria's registry, which the Portal also registers into, had no place for it, so a Portal function's meaning lived only in prose. + +**Change:** `AnalyticFunctionSpec.declaration: Optional[AnalyticDeclaration]`, plus `MeasureDeclaration`, `BasisDeclaration`, `AbsenceSentences`, `RESULT_KINDS` and `ABSENCE_STATES` in `pyegeria/view/analytic_registry.py`. It is the minimum of trellis's model: `result_kind`, `measures` (required for measure/series/distribution/graph) and all four `absence` sentences. Field names and vocabulary are the same as trellis's. Trellis's richer blocks (extents, confidence, answers, categories, ...) are ignored, not refused. Optional, so every existing entry loads unchanged. All 42 declarations in RE's `analysis_catalog.yaml` validate (checked 2026-10-30). trellis's `StepManifest` already overrides the field with its own `Declaration`, which still works. + +**Rule from the evaluation:** a field two consumers need lives here; a field one consumer needs stays in that consumer's subclass. Moving more of trellis's model up waits for a second consumer. + +Tests: `tests/micro-tests/test_issue_170_analytic_declaration.py`. + +--- + ### ISSUE-168: pyegeria can't run on Python 3.14: `nest_asyncio`, applied at import time, makes `asyncio.current_task()` return None **Layer:** pyegeria · **Status:** fix drafted on branch `fix/issue-168-py314-sync-runner` (not merged) · **Found:** 2026-10-29, when the owner asked why pyegeria caps Python at `<3.14`. diff --git a/pyegeria/view/analytic_registry.py b/pyegeria/view/analytic_registry.py index 7c8ebf5b..b7c21593 100644 --- a/pyegeria/view/analytic_registry.py +++ b/pyegeria/view/analytic_registry.py @@ -42,12 +42,18 @@ from typing import Any, Dict, List, Literal, Optional, Union from loguru import logger -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator __all__ = [ 'AnalyticParam', 'AnalyticFunctionSpec', 'AnalyticActionSpec', + 'AnalyticDeclaration', + 'MeasureDeclaration', + 'BasisDeclaration', + 'AbsenceSentences', + 'RESULT_KINDS', + 'ABSENCE_STATES', 'KNOWN_ANALYTIC_SOURCES', 'AnalyticRegistryCollision', 'refresh_analytic_functions', @@ -105,6 +111,96 @@ class AnalyticActionSpec(BaseModel): analytic_params: List[AnalyticParam] = Field(default_factory=list) +# Declaration (ISSUE-170): what an analytic function says about its own results, +# so a screen, report or chat answer can show them with no code written for that +# function. The minimum of trellis-analytics' `Declaration` (result kind, measures +# with unit/as-of/basis, the four absence sentences), with the same field names and +# vocabulary, so a trellis declaration validates here too; its richer blocks +# (extents, confidence, answers, ...) are ignored by this model, not refused. + +#: The fixed result-kind vocabulary (trellis `RESULT_KINDS`). +RESULT_KINDS = ("measure", "series", "distribution", "list", "graph", "finding", "proposal", "table") + +#: The four ways a result can be absent, each a sentence the function declares. Envelope states +#: map onto them: nothing_found -> measured_nothing; not_established -> not_measured (or +#: not_permitted, told apart by the reason); not_applicable -> not_applicable. +ABSENCE_STATES = ("not_measured", "not_permitted", "not_applicable", "measured_nothing") + + +def _words(value: Any) -> str: + return " ".join(str(value or "").split()) + + +class BasisDeclaration(BaseModel): + """The whole a counted measure is part of, so "456 of 1,236 columns" is derived, not written.""" + denominator_field: str # the result field holding the whole, e.g. "column_count" + denominator_label: str # the whole in words, plural, e.g. "columns" + + @model_validator(mode="after") + def _filled(self) -> "BasisDeclaration": + if not _words(self.denominator_field) or not _words(self.denominator_label): + raise ValueError("a basis needs both denominator_field and denominator_label") + return self + + +class MeasureDeclaration(BaseModel): + """One number a function reports. A number never travels without its unit and its as-of.""" + name: str + unit: str # in words, plural where it counts things: "tables", "fraction of rows (0-1)" + as_of: str # the result field saying when this was measured, e.g. "asOf" + value_field: str # the result field holding the value; dotted for nested, trailing [] counts a list + basis: Optional[BasisDeclaration] = None + capped_field: str = "" # the result field that is true when the value is only a lower bound + + @model_validator(mode="after") + def _filled(self) -> "MeasureDeclaration": + empty = [f for f in ("name", "unit", "as_of", "value_field") if not _words(getattr(self, f))] + if empty: + raise ValueError(f"measure '{self.name}' declares no {', '.join(empty)}: " + "a number never travels without its unit and its as-of") + return self + + +class AbsenceSentences(BaseModel): + """The sentence shown for each way a result can be absent. All four are declared.""" + not_measured: str + not_permitted: str + not_applicable: str + measured_nothing: str + + @model_validator(mode="after") + def _filled(self) -> "AbsenceSentences": + empty = [s for s in ABSENCE_STATES if not _words(getattr(self, s))] + if empty: + raise ValueError(f"absence sentence for {', '.join(empty)} is empty: " + "an absence is declared, never improvised") + return self + + def sentence(self, state: str) -> str: + return getattr(self, state) + + +class AnalyticDeclaration(BaseModel): + """What an analytic function declares about its results: the minimum is `result_kind`, + `measures` (required for measure/series/distribution/graph) and `absence`.""" + result_kind: str + measures: List[MeasureDeclaration] = Field(default_factory=list) + absence: AbsenceSentences + + @field_validator("result_kind") + @classmethod + def _kind(cls, value: str) -> str: + if value not in RESULT_KINDS: + raise ValueError(f"result_kind '{value}' is not one of {', '.join(RESULT_KINDS)}") + return value + + @model_validator(mode="after") + def _measured_kinds_declare_measures(self) -> "AnalyticDeclaration": + if self.result_kind in ("measure", "series", "distribution", "graph") and not self.measures: + raise ValueError(f"result_kind '{self.result_kind}' declares no measures") + return self + + class AnalyticFunctionSpec(BaseModel): """One entry in the analytic function registry. @@ -147,6 +243,9 @@ class AnalyticFunctionSpec(BaseModel): resource_types: List[str] = Field(default_factory=list) # e.g. ["database"] family: str = "" # same word and meaning as FormatSet.family; in Egeria a family is a Collection tags: List[str] = Field(default_factory=list) + # What the results mean (ISSUE-170): result kind, measures with unit/as-of/basis, absence + # sentences. Optional; trellis-analytics' StepManifest narrows it to its fuller Declaration. + declaration: Optional[AnalyticDeclaration] = None @field_validator("source") @classmethod diff --git a/tests/micro-tests/test_issue_170_analytic_declaration.py b/tests/micro-tests/test_issue_170_analytic_declaration.py new file mode 100644 index 00000000..29e7aad8 --- /dev/null +++ b/tests/micro-tests/test_issue_170_analytic_declaration.py @@ -0,0 +1,75 @@ +"""ISSUE-170: an optional declaration on AnalyticFunctionSpec -- result kind, measures +with unit/as-of/basis, and the four absence sentences, mirroring trellis-analytics.""" +import pytest +from pydantic import ValidationError + +from pyegeria.view.analytic_registry import ( + AnalyticDeclaration, + AnalyticFunctionSpec, + analytic_registry_payload, + clear_runtime_analytic_functions, + get_analytic_registry, + register_analytic_functions, +) + +ABSENCE = { + "not_measured": "Coverage was not measured: the catalog could not be read.", + "not_permitted": "Coverage was not measured: reading the catalog is not permitted.", + "not_applicable": "Coverage does not apply to this resource.", + "measured_nothing": "No columns were found to cover.", +} + +DECLARATION = { + "result_kind": "measure", + "measures": [{ + "name": "described_columns", "unit": "columns", "as_of": "asOf", "value_field": "described", + "basis": {"denominator_field": "column_count", "denominator_label": "columns"}, + }], + "absence": ABSENCE, +} + + +def test_existing_specs_load_without_a_declaration(): + assert all(spec.declaration is None for spec in get_analytic_registry().values()) + + +def test_declaration_round_trips_on_a_spec(): + spec = AnalyticFunctionSpec(name="coverage", function="pyegeria.view.overview_metrics.growth_series", + declaration=DECLARATION) + m = spec.declaration.measures[0] + assert (m.unit, m.as_of, m.basis.denominator_label) == ("columns", "asOf", "columns") + assert spec.declaration.absence.sentence("measured_nothing") == ABSENCE["measured_nothing"] + assert AnalyticFunctionSpec(**spec.model_dump()).declaration == spec.declaration + + +def test_registry_payload_carries_the_declaration(): + clear_runtime_analytic_functions() + try: + register_analytic_functions({"test-170": {"name": "test-170", "declaration": DECLARATION, + "function": "pyegeria.view.overview_metrics.growth_series"}}) + payload = analytic_registry_payload()["functions"]["test-170"] + assert payload["declaration"]["absence"]["not_measured"] == ABSENCE["not_measured"] + finally: + clear_runtime_analytic_functions() + + +def test_richer_trellis_blocks_are_accepted_and_ignored(): + rich = dict(DECLARATION, extents=[{"name": "x"}], confidence={"form": "measured"}, answers=["Q-0001"]) + assert AnalyticDeclaration.model_validate(rich).result_kind == "measure" + + +@pytest.mark.parametrize("bad, words", [ + (dict(DECLARATION, result_kind="chart"), "not one of"), + (dict(DECLARATION, measures=[]), "declares no measures"), + (dict(DECLARATION, absence=dict(ABSENCE, not_permitted=" ")), "never improvised"), + (dict(DECLARATION, measures=[{"name": "m", "unit": "", "as_of": "asOf", "value_field": "v"}]), "unit"), + (dict(DECLARATION, measures=[dict(DECLARATION["measures"][0], + basis={"denominator_field": "n", "denominator_label": ""})]), "basis"), +]) +def test_incomplete_declarations_are_refused_with_a_sentence(bad, words): + with pytest.raises(ValidationError, match=words): + AnalyticDeclaration.model_validate(bad) + + +def test_unmeasured_kinds_need_no_measures(): + assert AnalyticDeclaration(result_kind="finding", absence=ABSENCE).measures == []