Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion PYEGERIA_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-169: `_resolve_analytic_function` imported any dotted path a report spec named; now registered names plus an allowlist of module prefixes
- 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)
Expand All @@ -194,9 +195,23 @@ 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-169: `_resolve_analytic_function` imported any dotted path a report spec named; now registered names plus an allowlist of module prefixes

**Layer:** pyegeria · **Status:** fix in PR (branch `fix/analytic-resolver-allowlist`) · **Found:** 2026-10-30, Resource Explorer's portal-extensibility evaluation (gap G6, trellis `docs/design-notes/EVAL-PORTAL-EXTENSIBILITY-VS-RE-2026-10-30.md`).
**Layer:** pyegeria · **Status:** fixed in #516 (merged 2026-10-30), ships in the next release · **Found:** 2026-10-30, Resource Explorer's portal-extensibility evaluation (gap G6, trellis `docs/design-notes/EVAL-PORTAL-EXTENSIBILITY-VS-RE-2026-10-30.md`).

**What:** `format_set_executor._resolve_analytic_function` ran `importlib.import_module` on whatever module a report spec's `analytic_function` (or an action's `fetch`/`analytic` step, or an `analytic_binders` factory) named. A report spec is data, which can come from a user's report-spec directory or a config tier, so it could make the executor import and call any module on the path.

Expand Down
101 changes: 100 additions & 1 deletion pyegeria/view/analytic_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions tests/micro-tests/test_issue_170_analytic_declaration.py
Original file line number Diff line number Diff line change
@@ -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 == []
Loading