From 43f6e38cb0bf940701f77e4060dc53cc797379a9 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sun, 8 Nov 2026 18:06:06 +0000 Subject: [PATCH] fix(overview-metrics): a capped or timed-out count is not measured, never the cap Live on build 082ecd44 the native count of a supertype (Asset, Process) times out at the client's 30 s limit; _element_count fell back to len(find), which stops at DEFAULT_CAP, and non-strict mode returned that 500 as the total ("Total Cataloged Assets = 500", "Processes = 500", Semantic Grounding "500 links / 100%"). - _element_count: a fallback page that reached DEFAULT_CAP is None in either mode, never the cap. count_relationships does the same for a full get_relationships page. Each such None records a note {count, reason, at_least} for recording_unmeasured() (a ContextVar), e.g. "the native count timed out, and the find fallback stopped at its 500-element page cap, so there are at least 500". - exec_report_spec collects the notes around an analytic routine: the output gains "unmeasured"; a scalar count that was not measured, and any SERIES/BAR/PIE over one, become kind "absent" with the reason and lower bound in content (no chart: a missing bar would read as 0). - Count demo specs (Element Count by Type, Assets by Type Breakdown, Count by Property, People and Community, Usage Context) ask for strict=True; sum_type_counts fetches strictly; internal callers (semantic_grounding, orphan_glossary_terms, stale_assets, contextualised_coverage, solution_component_realisation, growth_series) count strictly; sum_counts' total is None when any row is. - Declared not_measured sentences for count_elements(_by_property), counts_by_type and semantic_grounding name timeouts and the cap. Measured results are byte-identical; only failure and capped results change, from 0 or 500 to None with the reason. Signed-off-by: Dan Wolfson --- pyegeria/view/analytic_demo_specs.py | 11 +- pyegeria/view/analytic_registry.py | 20 +- pyegeria/view/format_set_executor.py | 66 ++++- pyegeria/view/overview_metrics.py | 155 ++++++++--- .../test_capped_count_not_measured.py | 254 ++++++++++++++++++ .../test_issue_123_strict_counts.py | 5 +- 6 files changed, 454 insertions(+), 57 deletions(-) create mode 100644 tests/micro-tests/test_capped_count_not_measured.py diff --git a/pyegeria/view/analytic_demo_specs.py b/pyegeria/view/analytic_demo_specs.py index 1e4ae144..c7fc8cc5 100644 --- a/pyegeria/view/analytic_demo_specs.py +++ b/pyegeria/view/analytic_demo_specs.py @@ -85,7 +85,7 @@ def _demo( heading="Element Count by Type", what="Counts active GlossaryTerm elements.", analytic_function_name="count_elements", - demo_params={"type_name": "GlossaryTerm"}, + demo_params={"type_name": "GlossaryTerm", "strict": True}, result_shape="scalar (int)", ), "Analytic Demo - Assets by Type Breakdown": _demo( @@ -96,7 +96,7 @@ def _demo( demo_params={"type_map": [ ["Data Stores", "DataStore"], ["Data Sets", "DataSet"], ["Software Components", "DeployedSoftwareComponent"], - ]}, + ], "strict": True}, result_shape="list[dict] ({label, type, count})", chart_types=["BAR", "PIE"], ), @@ -156,7 +156,7 @@ def _demo( heading="People & Community Counts", what="Counts of Person/Team/Organization/ITProfile/Community actor profiles.", analytic_function_name="people_counts", - demo_params={}, + demo_params={"strict": True}, result_shape="dict (persons, teams, organizations, itProfiles, communities)", chart_types=["BAR", "PIE"], ), @@ -175,7 +175,7 @@ def _demo( what="InformationSupplyChain and SolutionBlueprint counts -- the two structures that put " "assets in a business usage context (supply chain membership, blueprint realization).", analytic_function_name="usage_context_counts", - demo_params={}, + demo_params={"strict": True}, result_shape="dict (informationSupplyChains, blueprints)", ), "Analytic Demo - Semantic Grounding": _demo( @@ -274,7 +274,8 @@ def _demo( heading="Count Elements by Property", what="Counts active DigitalProduct elements with deploymentStatus=ACTIVE.", analytic_function_name="count_elements_by_property", - demo_params={"type_name": "DigitalProduct", "property_name": "deploymentStatus", "property_value": "ACTIVE"}, + demo_params={"type_name": "DigitalProduct", "property_name": "deploymentStatus", "property_value": "ACTIVE", + "strict": True}, result_shape="scalar (int)", ), "Analytic Demo - Contextualised Coverage": _demo( diff --git a/pyegeria/view/analytic_registry.py b/pyegeria/view/analytic_registry.py index e2dfe80e..d3e9e62a 100644 --- a/pyegeria/view/analytic_registry.py +++ b/pyegeria/view/analytic_registry.py @@ -858,8 +858,9 @@ def _always_applies(what: str) -> str: result_kind="measure", measures=[_measure("elements", "elements", "value")], absence=_absence( - not_measured="The elements were not counted: the count query failed, or the server " - "could not answer it in the population asked for.", + not_measured="The elements were not counted: the count query failed or timed out, or " + "the server could not answer it in the population asked for. A fallback " + "that stopped at its page cap is a lower bound, never a total.", not_permitted="You are not permitted to count these elements.", not_applicable="This count does not apply: the type named is not an element type " "the server knows.", @@ -891,8 +892,9 @@ def _always_applies(what: str) -> str: # The caller picks the types; a supertype and its subtype may both be listed. categories=CategoriesDeclaration(exclusive=False), absence=_absence( - not_measured="The types were not counted: the count queries failed, or the server " - "could not answer them in the population asked for.", + not_measured="The types were not counted: the count queries failed or timed out, or " + "the server could not answer them in the population asked for. A fallback " + "that stopped at its page cap is a lower bound, never a total.", not_permitted="You are not permitted to count elements of these types.", not_applicable="A breakdown by type does not apply: no types were given to count.", nothing_found="Counted: there are no active elements of any of these types.", @@ -1183,8 +1185,9 @@ def _always_applies(what: str) -> str: "at most 100", "groundingPct"), ], absence=_absence( - not_measured="Semantic grounding was not measured: the relationship query failed or " - "there was no Asset count to divide by.", + not_measured="Semantic grounding was not measured: the SemanticAssignment count or the " + "Asset count to divide by failed, timed out, or stopped at its page cap " + "(a lower bound, never a total).", not_permitted="You are not permitted to read the semantic assignments.", not_applicable=_always_applies("Semantic grounding"), nothing_found="Counted: no glossary term is assigned to anything.", @@ -1518,8 +1521,9 @@ def _always_applies(what: str) -> str: result_kind="measure", measures=[_measure("elements", "elements", "value")], absence=_absence( - not_measured="The elements were not counted: the count query failed, or the server " - "could not answer it in the population asked for.", + not_measured="The elements were not counted: the count query failed or timed out, or " + "the server could not answer it in the population asked for. A fallback " + "that stopped at its page cap is a lower bound, never a total.", not_permitted="You are not permitted to count these elements.", not_applicable="This count does not apply: the type or property named is not one " "the server knows.", diff --git a/pyegeria/view/format_set_executor.py b/pyegeria/view/format_set_executor.py index 3fa13daf..40ed317a 100644 --- a/pyegeria/view/format_set_executor.py +++ b/pyegeria/view/format_set_executor.py @@ -18,7 +18,7 @@ import json import os import sys -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union from loguru import logger from pydantic import ValidationError @@ -38,6 +38,7 @@ from pyegeria.view.analytic_registry import get_analytic_registry as get_analytic_registry_functions from pyegeria.egeria_tech_client import EgeriaTech from pyegeria.view.role_supply import PlainSupply, RoleRefused, RoleSupply, supply_inputs +from pyegeria.view.overview_metrics import recording_unmeasured _CLIENT_CLASS_MAP = { "CollectionManager": "pyegeria.omvs.collection_manager.CollectionManager", @@ -359,13 +360,40 @@ def _absence_for(envelope: Optional[Dict[str, str]], func_decl: str) -> Optional "declared": False} -def _absent_output(result: Any, envelope: Dict[str, str], absence: Dict[str, Any]) -> Dict[str, Any]: +def _run_recording_unmeasured(action: dict, **kwargs: Any) -> Tuple[Any, List[Dict[str, Any]]]: + """Run the analytic routine and collect a note for every count in it that was not measured + (a failed or timed-out count, or a find fallback that stopped at its page cap -- a lower + bound, never a total). See overview_metrics.recording_unmeasured.""" + with recording_unmeasured() as notes: + result = _run_analytic_function(action, **kwargs) + unique: List[Dict[str, Any]] = [] # a series repeats the same note once per point + for note in notes: + if note not in unique: + unique.append(note) + return result, unique + + +def _unmeasured_envelope(notes: List[Dict[str, Any]]) -> Dict[str, str]: + """The not_established envelope for a result whose value is a count that was not measured; + the reason is each note's own words, so the lower bound ("at least 500") reaches the reader.""" + reason = "; ".join(f"{n['count']}: {n['reason']}" for n in notes) + return {"state": "not_established", "reason": reason, "subtitle": f"not established \u00b7 {reason}"} + + +def _absent_output(result: Any, envelope: Dict[str, str], absence: Dict[str, Any], + notes: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: """Output for a result whose value is not established or does not apply: the sentence, never top-level data a chart or table would draw as an answer. Any partial result rides - under `detail`.""" - out: Dict[str, Any] = {"kind": "absent", "content": absence["sentence"], "absence": absence} + under `detail`. With `notes` (counts that were not measured), `content` also says which + and why, lower bound included, and the notes ride under `unmeasured`.""" + content = absence["sentence"] + if notes: + content = f"{content} Not measured: " + "; ".join(f"{n['count']}: {n['reason']}" for n in notes) + "." + out: Dict[str, Any] = {"kind": "absent", "content": content, "absence": absence} if result not in (None, [], {}): out["detail"] = result + if notes: + out["unmeasured"] = notes return _with_envelope(out, envelope) @@ -867,18 +895,22 @@ def _exec_analytic_chart( f"{chart_kind.upper()} output isn't supported for this spec." ) - result = _run_analytic_function( + result, notes = _run_recording_unmeasured( action, params=params, view_server=view_server, view_url=view_url, user=user, user_pass=user_pass, token=token, supply=supply, ) envelope = _result_envelope(result) result = _jsonable(result) + # A count in the result was not measured: its value is None, and a chart would draw that as + # a missing bar, a gap or a re-proportioned pie. Withhold the chart and say why instead. + if notes and (envelope is None or envelope["state"] in CHARTABLE_STATES): + envelope = _unmeasured_envelope(notes) absence = _absence_for(envelope, action["analytic_function"]) # Chart only an established answer (ISSUE-140): measured, or nothing_found (a real # zero). For not_established / not_applicable the numbers aren't known, so say so # (ISSUE-171), never a chart whose zeros would read as an answer. if envelope and envelope["state"] not in CHARTABLE_STATES: - return _absent_output(result, envelope, absence) + return _absent_output(result, envelope, absence, notes) if not result: return _with_envelope({"kind": "empty", **({"absence": absence} if absence else {})}, envelope) @@ -984,6 +1016,14 @@ def exec_report_spec( SERIES/BAR/PIE the subtitle is put on the chart, and no chart is drawn unless the state is `measured` or `nothing_found` (ISSUE-140). + A count inside an analytic result that was not measured -- it failed, timed out, or its + find fallback stopped at the page cap, which is a lower bound and never a total -- is + None in the data, never 0 or the cap, and the output carries `"unmeasured": [{"count", + "reason", "at_least"}, ...]` naming each one. A scalar result that is such a count, and + any SERIES/BAR/PIE whose result holds one, come back as `kind: "absent"` (no chart: a + missing bar or a re-proportioned pie would read as an answer), with the reason and the + lower bound in `content`. + ISSUE-86: `token`, when given, authenticates the client(s) built here with `set_bearer_token(token)` instead of minting a fresh token from `user`/`user_pass` via `create_egeria_bearer_token()`. Use this when the @@ -1074,19 +1114,25 @@ def exec_report_spec( # docstring describes) still falls through to the find path below for # anything other than SERIES, unchanged. if action.get("analytic_function") and not func_decl: - result = _run_analytic_function( + result, notes = _run_recording_unmeasured( action, params=params, view_server=view_server, view_url=view_url, user=user, user_pass=user_pass, token=token, supply=supply, ) envelope = _result_envelope(result) result = _jsonable(result) + # The value itself is a count that was not measured (a scalar None): say so, with the + # reason and any lower bound, never "empty". A dict or list keeps its measured values; + # its unmeasured ones are None and the notes below name each one. + if notes and envelope is None and result is None: + envelope = _unmeasured_envelope(notes) + unmeasured = {"unmeasured": notes} if notes else {} absence = _absence_for(envelope, action["analytic_function"]) if envelope and envelope["state"] not in CHARTABLE_STATES: - return _absent_output(result, envelope, absence) # ISSUE-171 + return _absent_output(result, envelope, absence, notes) # ISSUE-171 extra = {"absence": absence} if absence else {} if result is None or result == [] or result == {}: - return _with_envelope({"kind": "empty", **extra}, envelope) - return _with_envelope({"kind": "json", "data": result, **extra}, envelope) + return _with_envelope({"kind": "empty", **extra, **unmeasured}, envelope) + return _with_envelope({"kind": "json", "data": result, **extra, **unmeasured}, envelope) required_params = action.get("required_params", action.get("user_params", [])) or [] optional_params = action.get("optional_params", []) or [] diff --git a/pyegeria/view/overview_metrics.py b/pyegeria/view/overview_metrics.py index b8efef0c..23232763 100644 --- a/pyegeria/view/overview_metrics.py +++ b/pyegeria/view/overview_metrics.py @@ -24,6 +24,8 @@ for the original inline versions this was extracted from. """ +import contextlib +import contextvars import functools import inspect import time @@ -42,6 +44,7 @@ __all__ = [ "DEFAULT_CAP", + "recording_unmeasured", "GOVERNANCE_CLASSIFICATIONS", "count_elements", "count_relationships", @@ -299,6 +302,63 @@ def _push_down(scope: Optional[str]) -> Optional[bool]: raise ValueError(f"scope must be None, {SCOPE_VIEWER!r} or {SCOPE_REPOSITORY!r}; got {scope!r}") +# ── not-measured notes ───────────────────────────────────────────────────── +# A count that comes back None is "not measured", never 0 and never the find +# cap. The value alone cannot say why, or what lower bound the fallback did +# see, so every count that returns None for that reason also records a note +# here; `recording_unmeasured()` collects them for whoever wants the words +# (the report-spec executor puts them on its output, see format_set_executor). +_UNMEASURED: "contextvars.ContextVar[Optional[List[Dict[str, Any]]]]" = contextvars.ContextVar( + "pyegeria_unmeasured_counts", default=None) + + +@contextlib.contextmanager +def recording_unmeasured(): + """Collect a note for every count that is not measured while the block runs. + + Yields a list that fills with {"count": what was counted, "reason": why it is + not measured, "at_least": the lower bound the find fallback saw, or None}. + Notes are per context (a ContextVar), so a worker thread records into its own + block, not its caller's.""" + notes: List[Dict[str, Any]] = [] + token = _UNMEASURED.set(notes) + try: + yield notes + finally: + _UNMEASURED.reset(token) + + +def _not_measured(what: str, reason: str, at_least: Optional[int] = None) -> None: + logger.warning(f"overview_metrics: count of {what!r} not measured -- {reason}") + notes = _UNMEASURED.get() + if notes is not None: + notes.append({"count": what, "reason": reason, "at_least": at_least}) + + +def _native_failure_words(exc: Optional[BaseException], tried: bool) -> str: + """What happened to the native count, in words, for a not-measured reason.""" + if not tried: + return "no native count is available" + if exc is None: + return "the native count gave no number" + text = str(exc).lower() + if isinstance(exc, PyegeriaTimeoutException) or "timed out" in text or "timeout" in text or "408" in text: + return "the native count timed out" + return f"the native count failed ({type(exc).__name__})" + + +def _count_label(body: dict) -> str: + label = body.get("metadataElementTypeName") or "elements" + conditions = ((body.get("matchClassifications") or {}).get("conditions")) or [] + names = [c.get("name") for c in conditions if isinstance(c, dict) and c.get("name")] + return f"{label} classified {'/'.join(names)}" if names else label + + +def _capped_reason(native_words: str, n: int) -> str: + return (f"{native_words}, and the find fallback stopped at its {DEFAULT_CAP}-element page cap, " + f"so there are at least {n}") + + def _element_count(mgr, body: dict, as_of: Optional[str] = None, strict: bool = False, scope: Optional[str] = None) -> Optional[int]: """Count elements matching a FindRequestBody -- native when available, else @@ -310,37 +370,45 @@ def _element_count(mgr, body: dict, as_of: Optional[str] = None, strict: bool = a viewer number masquerading as a repository one. strict=False: 0 when the fallback query fails (the historical behaviour). - strict=True: None when the count is not known -- the fallback query failed, - or it returned a full page (DEFAULT_CAP), which is only a lower bound. + strict=True: None when the fallback query fails. + Either way: None when the fallback returned a full page (DEFAULT_CAP). That + is only a lower bound, never a total, so it is never returned as one; the + bound and the reason go to `recording_unmeasured()`'s notes. """ push_down = _push_down(scope) kw = {} if push_down is None else {"push_down": push_down} b = dict(body) if as_of: b["asOfTime"] = as_of + what = _count_label(body) name = _native_count_method(mgr, _ELEMENT_COUNT_CANDIDATES, "elem:" + type(mgr).__name__) + tried, native_exc = False, None if name and not _native_disabled(mgr): + tried = True try: c = _as_count(getattr(mgr, name)(b, **kw)) if c is not None: return c except Exception as exc: # noqa: BLE001 -- old server: mark unsupported, fall back + native_exc = exc _disable_native(mgr, exc) + native_words = _native_failure_words(native_exc, tried) if scope == SCOPE_REPOSITORY: - logger.warning(f"overview_metrics: repository-scope count of " - f"{body.get('metadataElementTypeName') or 'elements'!r} not measured -- " + if strict: + _not_measured(what, f"{native_words}, and a repository-population count has no fallback " + f"(the find fallback is viewer-scoped)") + return None + logger.warning(f"overview_metrics: repository-scope count of {what!r} not measured -- " f"native count unavailable or failed (the find fallback is viewer-scoped)") - return None if strict else 0 - if not strict: - return len(_find(mgr, b)) + return 0 found = _find_or_none(mgr, b) if found is None: - logger.warning(f"overview_metrics: count of {body.get('metadataElementTypeName') or 'elements'!r} " - f"not measured -- the query failed") + if not strict: + return 0 + _not_measured(what, f"{native_words}, and the find fallback failed") return None - if len(found) >= DEFAULT_CAP: - logger.warning(f"overview_metrics: count of {body.get('metadataElementTypeName') or 'elements'!r} " - f"not measured -- the fallback hit the {DEFAULT_CAP} cap, so {len(found)} is only a lower bound") + if len(found) >= DEFAULT_CAP: # a full page is a lower bound, never a total (strict or not) + _not_measured(what, _capped_reason(native_words, len(found)), at_least=len(found)) return None return len(found) @@ -361,7 +429,9 @@ def count_elements( By default a failed query counts as 0, so an outage looks like an empty catalog. Pass strict=True to get None instead whenever the number is not - known (ISSUE-123). + known (ISSUE-123). Strict or not, a find fallback that stops at DEFAULT_CAP + gives None, never the cap: that page is a lower bound, not a total (its + bound and reason are recorded for `recording_unmeasured()`). Parameters ---------- @@ -370,8 +440,8 @@ def count_elements( as_of : ISO-8601 asOfTime for a point-in-time count (None = now) classifications : classification names to match ANY of -- keep to a single call's worth (some connectors mishandle 2+ ANY conditions) - strict : when True, return None if the query failed, or if the fallback - count hit DEFAULT_CAP (a lower bound, not a total) + strict : when True, return None (not 0) if the query failed. A fallback + count that hit DEFAULT_CAP is None in either mode scope : None (server default), "viewer" (push_down=False: only what the caller may see) or "repository" (push_down=True: whole-repository raw count, fast, includes elements the caller cannot see). The len(find) @@ -380,7 +450,8 @@ def count_elements( Returns ------- - int -- 0 on any failure (never raises); with strict=True, Optional[int] + Optional[int] -- 0 on a failed query (never raises), None on a capped + fallback; with strict=True, None whenever the count is not measured """ body: Dict[str, Any] = {"class": "FindRequestBody", "limitResultsByStatus": ["ACTIVE"]} if type_name: @@ -424,7 +495,7 @@ def count_elements_by_property( Returns ------- - int -- 0 on any failure (never raises); with strict=True, Optional[int] + Optional[int] -- as for count_elements """ body: Dict[str, Any] = { "class": "FindRequestBody", @@ -473,13 +544,17 @@ def count_relationships(ce, relationship_type: str, as_of: Optional[str] = None, Returns ------- - Optional[int] -- None on total failure (never raises) + Optional[int] -- None on total failure, and None when the get_relationships + page came back full (DEFAULT_CAP): that is a lower bound, never a total + (its bound and reason are recorded for `recording_unmeasured()`). Never raises. """ push_down = _push_down(scope) kw = {} if push_down is None else {"push_down": push_down} + tried, native_exc = False, None if expert is not None and not _native_disabled(expert): name = _native_count_method(expert, _REL_COUNT_CANDIDATES, "rel:" + type(expert).__name__) if name: + tried = True body = {"class": "FindRelationshipRequestBody", "relationshipTypeName": relationship_type, "limitResultsByStatus": ["ACTIVE"]} if as_of: @@ -489,6 +564,7 @@ def count_relationships(ce, relationship_type: str, as_of: Optional[str] = None, if c is not None: return c except Exception as exc: # noqa: BLE001 + native_exc = exc _disable_native(expert, exc) if scope == SCOPE_REPOSITORY: logger.warning(f"overview_metrics: repository-scope count of {relationship_type!r} not measured -- " @@ -496,12 +572,18 @@ def count_relationships(ce, relationship_type: str, as_of: Optional[str] = None, return None try: body = {"class": "ResultsRequestBody", "asOfTime": as_of} if as_of else None - return len(_json_list(ce.get_relationships( + n = len(_json_list(ce.get_relationships( relationship_type=relationship_type, output_format="JSON", start_from=0, page_size=DEFAULT_CAP, body=body))) except Exception as exc: # noqa: BLE001 logger.debug(f"overview_metrics count_relationships({relationship_type}) failed: {exc}") return None + if n >= DEFAULT_CAP: + page = f"get_relationships stopped at its {DEFAULT_CAP}-relationship page cap, so there are at least {n}" + _not_measured(relationship_type, (f"{_native_failure_words(native_exc, tried)}, and {page}" if tried + else f"{page} (no native count was asked for)"), at_least=n) + return None + return n @_population_keyword @@ -511,7 +593,8 @@ def counts_by_type(mgr, type_map: Sequence[Tuple[str, str]], as_of: Optional[str Per-type counts for a set of (label, type_name) pairs -- e.g. the assets- by-type composition breakdown. Unknown/wrong types just yield 0 rather than failing the whole call. With strict=True, a count that could not be - measured is None (see count_elements). `scope` is passed to count_elements. + measured is None (see count_elements); a capped fallback is None either + way, never the cap. `scope` is passed to count_elements. Returns a list of {"label": ..., "type": ..., "count": ...} dicts, in the order given. @@ -547,7 +630,8 @@ def sum_type_counts(mgr, type_map: Sequence[Tuple[str, str]], as_of: Optional[st fetch="pyegeria.view.overview_metrics.counts_by_type", analytic="pyegeria.view.overview_metrics.sum_counts", ) - return run_analytic_action(spec, mgr, fetch_kwargs={"type_map": type_map, "as_of": as_of}) + # strict: a failed count is None, not 0, so the total is None rather than an undercount. + return run_analytic_action(spec, mgr, fetch_kwargs={"type_map": type_map, "as_of": as_of, "strict": True}) def sum_counts(by_type: List[Dict[str, Any]], mgr=None) -> Dict[str, Any]: @@ -557,8 +641,13 @@ def sum_counts(by_type: List[Dict[str, Any]], mgr=None) -> Dict[str, Any]: analytic step that DOES need to fetch more -- e.g. following a relationship to enrich, the pattern `ai_ready_assets` already uses inline). Also directly reusable on a `by_type` result a caller already - has, without re-fetching (see `sum_type_counts`'s docstring).""" - return {"total": sum(r["count"] for r in by_type), "byType": by_type} + has, without re-fetching (see `sum_type_counts`'s docstring). + + `total` is None when any row's count is None (not measured): a sum over + the measured rows only would read as the whole total.""" + counts = [r["count"] for r in by_type] + total = None if any(c is None for c in counts) else sum(counts) + return {"total": total, "byType": by_type} def _classifications_of(el: dict) -> set: @@ -873,7 +962,7 @@ def orphan_glossary_terms(mgr, ce, as_of: Optional[str] = None) -> Dict[str, Opt "orphanCount": int|None}. Any field is None on total failure for its own step (never raises). """ - term_total = count_elements(mgr, "GlossaryTerm", as_of) + term_total = count_elements(mgr, "GlossaryTerm", as_of, strict=True) referenced_count = None try: @@ -937,7 +1026,7 @@ def stale_assets(mgr, as_of: Optional[str] = None, days: int = 180) -> Dict[str, except Exception as exc: # noqa: BLE001 logger.debug(f"stale_assets: query failed: {exc}") - return {"staleCount": stale_count, "assetTotal": count_elements(mgr, "Asset", as_of)} + return {"staleCount": stale_count, "assetTotal": count_elements(mgr, "Asset", as_of, strict=True)} def semantic_grounding(mgr, ce, as_of: Optional[str] = None) -> Dict[str, Any]: @@ -948,7 +1037,7 @@ def semantic_grounding(mgr, ce, as_of: Optional[str] = None) -> Dict[str, Any]: Returns {"groundingLinks": int|None, "groundingPct": int|None}. """ - cataloged = count_elements(mgr, "Asset", as_of) + cataloged = count_elements(mgr, "Asset", as_of, strict=True) links = count_relationships(ce, "SemanticAssignment", as_of) pct = min(100, round(100 * links / cataloged)) if cataloged and links is not None else None return {"groundingLinks": links, "groundingPct": pct} @@ -1389,7 +1478,7 @@ def contextualised_coverage(mgr, ce, as_of: Optional[str] = None) -> Dict[str, O except Exception as exc: # noqa: BLE001 logger.debug(f"contextualised_coverage: ImplementedBy query failed: {exc}") - asset_total = count_elements(mgr, "Asset", as_of) + asset_total = count_elements(mgr, "Asset", as_of, strict=True) pct = None if contextualised_count is not None and asset_total: @@ -1434,7 +1523,7 @@ def solution_component_realisation(mgr, ce, as_of: Optional[str] = None) -> Dict int|None}. Either field is None on total failure for its own step (never raises). """ - total = count_elements(mgr, "SolutionComponent", as_of) + total = count_elements(mgr, "SolutionComponent", as_of, strict=True) realised_count = None try: @@ -1649,7 +1738,7 @@ def growth_series( point = {"label": growth_label(d, span_s), "date": d.strftime(date_fmt)} try: for key, type_name, classifications in type_map: - point[key] = count_elements(mgr, type_name, as_of, classifications=classifications) + point[key] = count_elements(mgr, type_name, as_of, classifications=classifications, strict=True) except Exception as exc: # noqa: BLE001 logger.debug(f"overview_metrics growth_series: snapshot {i} failed: {exc}") for key, _, _ in type_map: @@ -2009,10 +2098,12 @@ def _count_cell(mgr, type_name: str, as_of: Optional[str], scope: str, stamp: st started = time.monotonic() reason = "" try: - value = count_elements(mgr, type_name, as_of, strict=True, population=scope) + with recording_unmeasured() as notes: + value = count_elements(mgr, type_name, as_of, strict=True, population=scope) if value is None: - reason = (f"the {scope} count of {type_name} could not be measured " - f"(query failed, native count unavailable, or the fallback hit its cap)") + reason = (f"the {scope} count of {type_name} could not be measured: " + + ("; ".join(n["reason"] for n in notes) if notes + else "query failed, native count unavailable, or the fallback hit its cap")) except Exception as exc: # noqa: BLE001 -- the exception's words become the reason value = None reason = f"the {scope} count of {type_name} failed: {exc}" diff --git a/tests/micro-tests/test_capped_count_not_measured.py b/tests/micro-tests/test_capped_count_not_measured.py new file mode 100644 index 00000000..9c247d91 --- /dev/null +++ b/tests/micro-tests/test_capped_count_not_measured.py @@ -0,0 +1,254 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the ODPi Egeria project. +""" +A count that timed out is never reported as a measured total. + +Live (2026-11-08, platform build 082ecd44): the native count of a supertype (Asset, Process) +timed out at the client's 30 s limit, `_element_count` fell back to len(find), the find stopped +at DEFAULT_CAP, and the Portal tile showed "Total Cataloged Assets = 500". A full page is a lower +bound, never a total: the count is None ("not measured"), and the reason with the bound it saw +reaches the output. Every branch is executed here, not read. +""" +import json +from unittest.mock import MagicMock + +import pytest + +from pyegeria.view import format_set_executor as fse +from pyegeria.view import overview_metrics as om +from pyegeria.view.role_supply import Subject, Viewer + +CAP = om.DEFAULT_CAP +TIMEOUT = RuntimeError("TIMEOUT_ERROR_408 the request to .../by-search-conditions/count timed out") + + +@pytest.fixture(autouse=True) +def _reset_seam_caches(): + om._count_caps.clear() + om._native_server_ok.clear() + yield + om._count_caps.clear() + om._native_server_ok.clear() + + +def _mgr(native=None, find_result=None, find_error=None): + attrs = ["platform_url", "view_server", "find_metadata_elements"] + if native is not None: + attrs.append("count_metadata_elements") + m = MagicMock(spec=attrs) + m.platform_url, m.view_server = "https://localhost:9443", "test-server" + if native is not None: + m.count_metadata_elements = native + if find_error is not None: + m.find_metadata_elements.side_effect = find_error + else: + m.find_metadata_elements.return_value = find_result + return m + + +def _page(n): + return [{"elementGUID": f"g{i}"} for i in range(n)] + + +# ── the count seam ── + +def test_a_timeout_whose_fallback_hits_the_cap_is_not_measured_with_its_lower_bound(): + mgr = _mgr(native=MagicMock(side_effect=TIMEOUT), find_result=_page(CAP)) + with om.recording_unmeasured() as notes: + for strict in (True, False): + assert om.count_elements(mgr, "Asset", strict=strict) is None # never CAP + assert notes == [{"count": "Asset", "at_least": CAP, + "reason": f"the native count timed out, and the find fallback stopped at its " + f"{CAP}-element page cap, so there are at least {CAP}"}] * 2 + + +def test_a_timeout_whose_fallback_fails_is_not_measured_strict(): + mgr = _mgr(native=MagicMock(side_effect=TIMEOUT), find_error=RuntimeError("boom")) + with om.recording_unmeasured() as notes: + assert om.count_elements(mgr, "Process", strict=True) is None + assert notes == [{"count": "Process", "at_least": None, + "reason": "the native count timed out, and the find fallback failed"}] + + +def test_the_timeout_leaves_native_enabled_for_the_next_count(): + native = MagicMock(side_effect=[TIMEOUT, {"count": 7}]) + mgr = _mgr(native=native, find_result=_page(CAP)) + assert om.count_elements(mgr, "Asset", strict=True) is None + assert om.count_elements(mgr, "DataStore", strict=True) == 7 + + +def test_a_short_fallback_page_is_a_measured_count_and_records_nothing(): + mgr = _mgr(native=MagicMock(side_effect=TIMEOUT), find_result=_page(CAP - 1)) + with om.recording_unmeasured() as notes: + assert om.count_elements(mgr, "Asset") == CAP - 1 + assert om.count_elements(mgr, "Asset", strict=True) == CAP - 1 + assert notes == [] + + +def test_a_native_count_above_the_cap_is_trusted(): + with om.recording_unmeasured() as notes: + assert om.count_elements(_mgr(native=MagicMock(return_value={"count": 1061})), "GlossaryTerm") == 1061 + assert notes == [] + + +def test_non_strict_failure_keeps_its_historical_zero(): + assert om.count_elements(_mgr(find_error=RuntimeError("boom")), "Asset") == 0 + + +def test_a_classification_count_names_what_it_counted(): + mgr = _mgr(find_result=_page(CAP)) + with om.recording_unmeasured() as notes: + assert om.count_elements(mgr, None, classifications=["Confidentiality", "Impact"]) is None + assert notes[0]["count"] == "elements classified Confidentiality/Impact" + assert notes[0]["reason"].startswith("no native count is available") + + +def test_notes_are_only_kept_inside_a_recording_block(): + mgr = _mgr(find_result=_page(CAP)) + assert om.count_elements(mgr, "Asset") is None # no block: nothing to append to, no error + with om.recording_unmeasured() as outer: + with om.recording_unmeasured() as inner: + om.count_elements(mgr, "Asset") + om.count_elements(mgr, "Process") + assert [n["count"] for n in inner] == ["Asset"] + assert [n["count"] for n in outer] == ["Process"] + + +def test_a_full_relationship_page_is_not_a_relationship_count(): + ce = MagicMock() + ce.get_relationships.return_value = [{"guid": f"r{i}"} for i in range(CAP)] + with om.recording_unmeasured() as notes: + assert om.count_relationships(ce, "SemanticAssignment") is None + assert notes[0]["count"] == "SemanticAssignment" and notes[0]["at_least"] == CAP + ce.get_relationships.return_value = [{"guid": "r1"}] + assert om.count_relationships(ce, "SemanticAssignment") == 1 + + +def test_semantic_grounding_no_longer_reports_500_links_100_percent(): + mgr = _mgr(native=MagicMock(side_effect=TIMEOUT), find_result=_page(CAP)) + ce = MagicMock() + ce.get_relationships.return_value = [{"guid": f"r{i}"} for i in range(CAP)] + with om.recording_unmeasured() as notes: + assert om.semantic_grounding(mgr, ce) == {"groundingLinks": None, "groundingPct": None} + assert {n["count"] for n in notes} == {"Asset", "SemanticAssignment"} + + +def test_counts_by_type_and_its_sum_never_add_a_lower_bound_into_a_total(): + native = MagicMock(side_effect=lambda body, **kw: ( + {"count": 843} if body["metadataElementTypeName"] == "DataStore" else (_ for _ in ()).throw(TIMEOUT))) + mgr = _mgr(native=native, find_result=_page(CAP)) + rows = om.counts_by_type(mgr, [("Data Stores", "DataStore"), ("Processes", "Process")]) + assert rows == [{"label": "Data Stores", "type": "DataStore", "count": 843}, + {"label": "Processes", "type": "Process", "count": None}] + assert om.sum_counts(rows)["total"] is None + assert om.sum_counts(rows[:1])["total"] == 843 + + +def test_an_asset_composition_cell_carries_the_lower_bound_in_its_reason(): + mgr = _mgr(native=MagicMock(side_effect=TIMEOUT), find_result=_page(CAP)) + cell = om._count_cell(mgr, "Asset", None, "viewer", "now") + assert cell["value"] is None and cell["state"] == om.CELL_NOT_MEASURED + assert f"so there are at least {CAP}" in cell["reason"] + + +# ── what a reader sees through exec_report_spec ── + +class _Fake: + """Native element counts time out except DataStore; the find fallback returns a full page.""" + platform_url, view_server = "https://fake:9443", "fake" + + def count_metadata_elements(self, body, **kw): + if body.get("metadataElementTypeName") == "DataStore": + return {"count": 843} + raise TIMEOUT + + def find_metadata_elements(self, body, *a, **kw): + return _page(min(CAP, body.get("pageSize") or CAP)) + + def get_relationships(self, *a, page_size=None, **kw): + return [{"guid": f"r{i}"} for i in range(min(CAP, page_size or CAP))] + + +class _Supply: + def __init__(self): + self.fake = _Fake() + + def client(self, cls): + return self.fake + + def viewer(self): + return Viewer(user_id="u", is_admin=False, display_name="U") + + def reader(self, source): + return None + + def subject(self): + return Subject(guid="g", type_name="Asset") + + def refusal_words(self, kind): + return f"cannot supply {kind}" + + +def _exec(label, fmt="DICT", **params): + return fse.exec_report_spec(label, output_format=fmt, params=params, view_server="vs", view_url="https://x", + user="u", user_pass="p", supply=_Supply()) + + +def test_the_total_cataloged_assets_tile_says_not_measured_with_at_least_500(): + out = _exec("Analytic Demo - Element Count by Type", type_name="Asset") + assert out["kind"] == "absent" and "data" not in out + assert out["absence"]["state"] == "not_measured" + assert out["unmeasured"] == [{"count": "Asset", "at_least": CAP, "reason": ( + f"the native count timed out, and the find fallback stopped at its {CAP}-element page cap, " + f"so there are at least {CAP}")}] + assert "Asset: the native count timed out" in out["content"] + assert f"at least {CAP}" in out["content"] + + +def test_a_measured_tile_is_unchanged(): + assert _exec("Analytic Demo - Element Count by Type", type_name="DataStore") == {"kind": "json", "data": 843} + + +def test_the_breakdown_rows_keep_measured_counts_and_name_the_unmeasured_one(): + out = _exec("Analytic Demo - Assets by Type Breakdown", + type_map=[["Data Stores", "DataStore"], ["Processes", "Process"]]) + assert out["kind"] == "json" + assert out["data"] == [{"label": "Data Stores", "type": "DataStore", "count": 843}, + {"label": "Processes", "type": "Process", "count": None}] + assert [(n["count"], n["at_least"]) for n in out["unmeasured"]] == [("Process", CAP)] + assert CAP not in [r["count"] for r in out["data"]] + + +@pytest.mark.parametrize("fmt", ["BAR", "PIE"]) +def test_no_chart_is_drawn_over_an_unmeasured_count(fmt): + out = _exec("Analytic Demo - Assets by Type Breakdown", fmt, + type_map=[["Data Stores", "DataStore"], ["Processes", "Process"]]) + assert out["kind"] == "absent" and "data" not in out # no vega spec: a missing bar would read as 0 + assert out["detail"][1] == {"label": "Processes", "type": "Process", "count": None} + assert "Process: the native count timed out" in out["content"] + + +def test_semantic_grounding_spec_reports_nulls_and_why(): + out = _exec("Analytic Demo - Semantic Grounding") + assert out["data"] == {"groundingLinks": None, "groundingPct": None} + assert {n["count"] for n in out["unmeasured"]} == {"Asset", "SemanticAssignment"} + assert all(n["at_least"] == CAP for n in out["unmeasured"]) + + +def test_a_series_repeats_no_note(): + out = _exec("Analytic Demo - Catalog Growth Trend", "SERIES") + assert out["kind"] == "absent" + keys = [json.dumps(n, sort_keys=True) for n in out["unmeasured"]] + assert len(keys) == len(set(keys)) + + +def test_no_rendered_output_carries_the_cap_as_a_count(): + for label, fmt, params in [ + ("Analytic Demo - Element Count by Type", "DICT", {"type_name": "Asset"}), + ("Analytic Demo - Assets by Type Breakdown", "DICT", {"type_map": [["P", "Process"]]}), + ("Analytic Demo - Assets by Type Breakdown", "BAR", {"type_map": [["P", "Process"]]}), + ("Analytic Demo - People and Community", "PIE", {}), + ]: + out = _exec(label, fmt, **params) + shown = {k: v for k, v in out.items() if k not in ("unmeasured", "absence", "envelope", "content", "detail")} + assert str(CAP) not in json.dumps(shown), (label, fmt, out) diff --git a/tests/micro-tests/test_issue_123_strict_counts.py b/tests/micro-tests/test_issue_123_strict_counts.py index 76c5b912..5cd6bd19 100644 --- a/tests/micro-tests/test_issue_123_strict_counts.py +++ b/tests/micro-tests/test_issue_123_strict_counts.py @@ -56,9 +56,10 @@ def test_strict_real_zero_is_zero(): assert om.count_elements(_mgr(find_result=[]), "Asset", strict=True) == 0 -def test_strict_returns_none_for_a_capped_fallback(): +def test_a_capped_fallback_is_none_in_either_mode_never_the_cap(): + # A full page is a lower bound, never a total: non-strict no longer returns the cap. full_page = [{"i": i} for i in range(om.DEFAULT_CAP)] - assert om.count_elements(_mgr(find_result=full_page), "Asset") == om.DEFAULT_CAP + assert om.count_elements(_mgr(find_result=full_page), "Asset") is None assert om.count_elements(_mgr(find_result=full_page), "Asset", strict=True) is None