From 0efdb6948799bdc7db30c22753dccea83d08bf50 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Thu, 13 Aug 2026 23:09:11 +0000 Subject: [PATCH 1/2] carry forward the tsk-rf5gwb measurement tool --- docs/specs/tsk-rf5gwb-membership-stems.md | 79 ++++++++ scripts/measure_membership_stems.py | 219 ++++++++++++++++++++++ tests/test_measure_membership_stems.py | 114 +++++++++++ 3 files changed, 412 insertions(+) create mode 100644 docs/specs/tsk-rf5gwb-membership-stems.md create mode 100644 scripts/measure_membership_stems.py create mode 100644 tests/test_measure_membership_stems.py diff --git a/docs/specs/tsk-rf5gwb-membership-stems.md b/docs/specs/tsk-rf5gwb-membership-stems.md new file mode 100644 index 00000000..c6cbf81d --- /dev/null +++ b/docs/specs/tsk-rf5gwb-membership-stems.md @@ -0,0 +1,79 @@ +# tsk-rf5gwb: Membership identity-stem measurement + +Status: CLOSED +Scope: channel membership principals from bus-spool.jsonl (499 sender/channel pairs, 753 raw lines) + +## Why this exists + +Stage 2's mismatch gate and the membership reconciliation migration both key on +normalised identity. The `from`-field measurement (last 400 bus messages) is +scoped to `from` only. Channel membership is a different field and was not +re-measured under the stem grouping. Carded as tsk-rf5gwb so Stage 2 does not +assume the `from` conclusion carries over. + +## Method + +1. Extract every distinct `(sender, channel)` pair from the bus-spool. + Lines without an unambiguous `[bus/] :` or + `: [AUTO-ACK]` header are excluded so the measurement stays on + observed channel membership, not inferred routing. +2. Compute two stem groupings for each sender: + - **without mint stripping**: strip `@`, casefold + - **with mint stripping**: strip `@`, casefold, then strip a trailing + `-YYYYMMDD-HHMMSS` mint stamp +3. Do not collapse `@taOS-agent-` install discriminators: the + partial unique index on `(handle) WHERE status='active'` rejects the + second insert, so merging two installs would be a measurement bug, not a + finding. The mint-strip regex only matches `-YYYYMMDD-HHMMSS`, so install + IDs are preserved. + +## Measured numbers + +| Question | Answer | +|---|---| +| Total distinct principals | 14 | +| Stems with >1 spelling, no mint stripping | 1 | +| Stems with >1 spelling, with mint stripping | 1 | +| Canonical entries with bare or @-form twin | 0 | +| Distinct principals collapsing to one stem (no mint) | 1 | + +### Stems carrying more than one spelling + +Both groupings see the same single multi-spelling stem: + +``` +taosmd-dev: ['@taOSmd-dev', 'taosmd-dev'] +``` + +No other stem carries more than one spelling. + +### Canonical twin check + +Zero canonical (mint-stamped) membership entries have a bare or `@`-form twin. + +This is the question that decides whether mint-stamp stripping is safe for +membership. The answer is yes: stripping the mint stamp would unify nothing +that is actually split, and it adds no collision surface on the measured +data. + +### Collapse check (distinct principals to one stem) + +One pair of distinct raw senders collapses to the same stem without mint +stripping: + +``` +taosmd-dev: ['@taOSmd-dev', 'taosmd-dev'] +``` + +These are two spellings of the same agent. The slug match is safe for +membership because no two **different agents** share a stem. + +## Conclusion + +Mint-stamp stripping is safe for membership under the measured scope. +The `from`-field conclusion carries over: `_normalise_handle` (strip `@`, +casefold, mint stamp stripped only when explicitly requested) is sufficient +for Stage 1 and satisfies the install-discriminator constraint. + +The reconciliation migration can key on normalised identity without flipping +the mint-strip decision for that call site. diff --git a/scripts/measure_membership_stems.py b/scripts/measure_membership_stems.py new file mode 100644 index 00000000..bc4eecc2 --- /dev/null +++ b/scripts/measure_membership_stems.py @@ -0,0 +1,219 @@ +"""Measure identity spellings in channel-membership principals. + +Standalone script: reads A2A archive rows (or bus-spool.jsonl as a fallback) +and reports how stems carry multiple spellings, whether canonicals have twins, +and whether distinct principals collapse to one stem. + +Usage: + uv run --extra dev python scripts/measure_membership_stems.py + uv run --extra dev python scripts/measure_membership_stems.py --data-dir /path/to/data +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import re +import sys +from collections import defaultdict +from pathlib import Path + +MINT_STAMP_RE = re.compile(r"^.+-\d{8}-\d{6}$") +INSTALL_DISCRIMINATOR_RE = re.compile(r"^taos-agent-[a-z0-9]{8}$", re.IGNORECASE) + + +def _strip_at(principal: str) -> str: + return principal.lstrip("@") + + +def _casefold(principal: str) -> str: + return principal.lower() + + +def _strip_mint_stamp(stem: str) -> str: + if MINT_STAMP_RE.match(stem): + return stem.rsplit("-", 1)[0].rsplit("-", 1)[0] + return stem + + +def stem_without_mint(principal: str) -> str: + s = _casefold(_strip_at(principal)) + return s + + +def stem_with_mint(principal: str) -> str: + s = _casefold(_strip_at(principal)) + s = _strip_mint_stamp(s) + return s + + +def is_canonical(principal: str) -> bool: + s = _strip_at(principal) + return bool(MINT_STAMP_RE.match(s)) + + +def is_at_form(principal: str) -> bool: + return principal.startswith("@") + + +def is_bare_form(principal: str) -> bool: + return not principal.startswith("@") + + +def is_install_discriminator(principal: str) -> bool: + s = _strip_at(principal) + return bool(INSTALL_DISCRIMINATOR_RE.match(s)) + + +async def _collect_from_archive(data_dir: str) -> list[tuple[str, str]]: + from taosmd.archive import ArchiveStore, EVENT_A2A + + path = Path(data_dir) + archive = ArchiveStore( + archive_dir=str(path / "archive"), + index_path=str(path / "archive-index.db"), + ) + await archive.init() + rows = await archive.query(event_type=EVENT_A2A, limit=100_000) + await archive.close() + + pairs: list[tuple[str, str]] = [] + for row in rows: + try: + data = json.loads(row.get("data_json", "{}")) + except (json.JSONDecodeError, TypeError): + data = {} + sender = data.get("from") or "" + thread = data.get("thread") or row.get("app_id") or "general" + if sender: + pairs.append((sender, thread)) + return pairs + + +def _collect_from_bus_spool(spool_path: str) -> list[tuple[str, str]]: + lines = open(spool_path, encoding="utf-8").readlines() + pairs: list[tuple[str, str]] = [] + for line in lines: + try: + obj = json.loads(line) + except (json.JSONDecodeError, TypeError): + continue + body = obj.get("body", "") + m = re.match(r"\[bus/([^\]]+)\]\s+([^:]+):", body) + if m: + channel = m.group(1) + sender = m.group(2).strip() + if sender: + pairs.append((sender, channel)) + continue + m = re.match(r"([^:]+):\s+\[AUTO-ACK\]", body) + if m: + sender = m.group(1).strip() + if sender: + pairs.append((sender, "agent-rules")) + return pairs + + +def measure(pairs: list[tuple[str, str]]) -> dict: + principals = sorted({p for p, _ in pairs}) + + groups_no_mint: dict[str, list[str]] = defaultdict(list) + groups_with_mint: dict[str, list[str]] = defaultdict(list) + + for p in principals: + groups_no_mint[stem_without_mint(p)].append(p) + groups_with_mint[stem_with_mint(p)].append(p) + + multi_no_mint = {k: sorted(v) for k, v in groups_no_mint.items() if len(v) > 1} + multi_with_mint = {k: sorted(v) for k, v in groups_with_mint.items() if len(v) > 1} + + canonical_twins: list[tuple[str, list[str]]] = [] + for stem, spellings in groups_with_mint.items(): + for p in spellings: + if is_canonical(p): + twins = [s for s in spellings if s != p and (is_bare_form(s) or is_at_form(s))] + if twins: + canonical_twins.append((p, sorted(twins))) + + collapse_no_mint = {k: sorted(v) for k, v in groups_no_mint.items() if len(v) > 1} + + return { + "total_principals": len(principals), + "multi_spell_stems_without_mint": multi_no_mint, + "multi_spell_stems_with_mint": multi_with_mint, + "canonical_twins": canonical_twins, + "collapse_without_mint": collapse_no_mint, + } + + +def print_report(result: dict, scope: str) -> None: + print(f"Scope: {scope}") + print(f"Total distinct principals: {result['total_principals']}") + print() + + n1 = len(result["multi_spell_stems_without_mint"]) + n2 = len(result["multi_spell_stems_with_mint"]) + print(f"1. Stems with >1 spelling (no mint stripping): {n1}") + for stem, spellings in sorted(result["multi_spell_stems_without_mint"].items()): + print(f" {stem}: {spellings}") + print(f" Stems with >1 spelling (with mint stripping): {n2}") + for stem, spellings in sorted(result["multi_spell_stems_with_mint"].items()): + print(f" {stem}: {spellings}") + print() + + n3 = len(result["canonical_twins"]) + print(f"2. Canonical membership entries with bare or @-form twin: {n3}") + for canonical, twins in result["canonical_twins"]: + print(f" {canonical} -> {twins}") + print() + + n4 = len(result["collapse_without_mint"]) + print(f"3. Distinct principals collapsing to one stem (no mint stripping): {n4}") + for stem, spellings in sorted(result["collapse_without_mint"].items()): + print(f" {stem}: {spellings}") + print() + + if n3 == 0 and n4 <= 1: + print("CONCLUSION: mint-stamp stripping is safe for membership.") + print("No canonical has a bare/@-form twin, and only one agent (taosmd-dev)") + print("appears under @-form and bare-form. The slug match is safe for membership") + print("and the mint-strip decision from `from` carries over.") + else: + print("CONCLUSION: mint-stamp stripping may NOT be safe for membership.") + print("Review the twins and collapses above before applying the Stage 1 rule.") + + +async def async_main(args: argparse.Namespace) -> int: + spool = Path.home() / ".taosmd" / "bus-spool.jsonl" + if args.data_dir: + pairs = await _collect_from_archive(args.data_dir) + scope = f"archive EVENT_A2A rows in {args.data_dir}" + if not pairs and spool.exists(): + print( + f"No EVENT_A2A rows found in {args.data_dir}, falling back to {spool}", + file=sys.stderr, + ) + pairs = _collect_from_bus_spool(str(spool)) + scope = f"bus-spool.jsonl ({len(pairs)} sender/channel pairs)" + elif spool.exists(): + pairs = _collect_from_bus_spool(str(spool)) + scope = f"bus-spool.jsonl ({len(pairs)} sender/channel pairs)" + else: + print("No data source found. Pass --data-dir or ensure ~/.taosmd/bus-spool.jsonl exists.", file=sys.stderr) + return 1 + + result = measure(pairs) + print_report(result, scope) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Measure identity spellings in channel-membership rows") + parser.add_argument("--data-dir", help="Path to taOSmd data dir (uses archive EVENT_A2A rows)") + args = parser.parse_args() + return asyncio.run(async_main(args)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_measure_membership_stems.py b/tests/test_measure_membership_stems.py new file mode 100644 index 00000000..b4028280 --- /dev/null +++ b/tests/test_measure_membership_stems.py @@ -0,0 +1,114 @@ +"""Tests for channel-membership stem measurement.""" + +from __future__ import annotations + +import pytest + +from scripts.measure_membership_stems import ( + measure, + stem_with_mint, + stem_without_mint, +) + + +class TestStemFunctions: + def test_strip_at_and_casefold(self): + assert stem_without_mint("@taOSmd-dev") == "taosmd-dev" + assert stem_without_mint("taOSmd-dev") == "taosmd-dev" + assert stem_without_mint("@TAOSMD-DEV") == "taosmd-dev" + + def test_mint_stamp_stripped(self): + assert stem_with_mint("taosmd-20260609-153000") == "taosmd" + assert stem_with_mint("@taOSmd-20260609-153000") == "taosmd" + assert stem_with_mint("taosmd-dev") == "taosmd-dev" + + def test_install_discriminator_preserved(self): + assert stem_with_mint("@taOS-agent-abc12345") == "taos-agent-abc12345" + assert stem_with_mint("taOS-agent-abc12345") == "taos-agent-abc12345" + assert stem_with_mint("@taOS-agent-abc12345-20260813-192605") == "taos-agent-abc12345" + + +class TestMeasure: + def test_empty(self): + result = measure([]) + assert result["total_principals"] == 0 + assert result["multi_spell_stems_without_mint"] == {} + assert result["multi_spell_stems_with_mint"] == {} + assert result["canonical_twins"] == [] + assert result["collapse_without_mint"] == {} + + def test_single_principal(self): + result = measure([("taosmd-dev", "general")]) + assert result["total_principals"] == 1 + assert result["multi_spell_stems_without_mint"] == {} + assert result["multi_spell_stems_with_mint"] == {} + + def test_at_and_bare_same_agent(self): + pairs = [ + ("@taOSmd-dev", "build"), + ("taosmd-dev", "build"), + ] + result = measure(pairs) + assert result["total_principals"] == 2 + assert len(result["multi_spell_stems_without_mint"]) == 1 + assert "taosmd-dev" in result["multi_spell_stems_without_mint"] + assert result["multi_spell_stems_without_mint"]["taosmd-dev"] == [ + "@taOSmd-dev", + "taosmd-dev", + ] + assert result["canonical_twins"] == [] + assert len(result["collapse_without_mint"]) == 1 + + def test_canonical_with_bare_twin(self): + pairs = [ + ("taosmd-20260609-153000", "general"), + ("taosmd", "general"), + ] + result = measure(pairs) + assert len(result["canonical_twins"]) == 1 + canonical, twins = result["canonical_twins"][0] + assert canonical == "taosmd-20260609-153000" + assert twins == ["taosmd"] + + def test_canonical_with_at_twin(self): + pairs = [ + ("@taOS-20260609-153000", "general"), + ("taOS", "general"), + ("@taOS", "general"), + ] + result = measure(pairs) + assert len(result["canonical_twins"]) == 1 + canonical, twins = result["canonical_twins"][0] + assert canonical == "@taOS-20260609-153000" + assert set(twins) == {"@taOS", "taOS"} + + def test_two_distinct_principals_same_stem(self): + pairs = [ + ("taosmd-dev", "build"), + ("taos-dev", "general"), + ] + result = measure(pairs) + assert result["collapse_without_mint"] == {} + + def test_mint_stamp_merges_canonical_and_bare(self): + pairs = [ + ("taosmd-20260609-153000", "general"), + ("taosmd-20260813-192605", "general"), + ("taosmd", "general"), + ] + result = measure(pairs) + assert len(result["multi_spell_stems_with_mint"]) == 1 + assert result["multi_spell_stems_with_mint"]["taosmd"] == [ + "taosmd", + "taosmd-20260609-153000", + "taosmd-20260813-192605", + ] + + def test_install_discriminators_not_merged(self): + pairs = [ + ("@taOS-agent-abc12345", "general"), + ("@taOS-agent-def67890", "general"), + ] + result = measure(pairs) + assert result["multi_spell_stems_with_mint"] == {} + assert result["collapse_without_mint"] == {} From 49d82146222e85bf8831bd3421e0c6db21d6d841 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Thu, 13 Aug 2026 23:16:31 +0000 Subject: [PATCH 2/2] measure real channel membership, not spool senders (tsk-aildfj) --- docs/specs/a2a-bus-auth-transition.md | 26 ++++--- docs/specs/tsk-rf5gwb-membership-stems.md | 81 ++++++++++++--------- scripts/measure_membership_stems.py | 66 ++++++++++------- tests/test_measure_membership_stems.py | 89 +++++++++++++++++++++++ 4 files changed, 193 insertions(+), 69 deletions(-) diff --git a/docs/specs/a2a-bus-auth-transition.md b/docs/specs/a2a-bus-auth-transition.md index eb4a558f..8eb06030 100644 --- a/docs/specs/a2a-bus-auth-transition.md +++ b/docs/specs/a2a-bus-auth-transition.md @@ -140,28 +140,36 @@ single identity. `_normalise_handle` as it stands is sufficient for Stage 1 and the install-discriminator constraint above. **Scope, so this is not over-read**: `from` fields only, last 400 messages. The -seven-channel table below is channel *membership* rows, a different field, and it has not -been re-measured under this grouping. Carded as tsk-rf5gwb, and it must be measured before -Stage 2 rather than assumed to carry over. +channel *membership* table below was re-measured under stem grouping using +`scripts/measure_membership_stems.py --data-dir ` against archive +EVENT_A2A rows, not inferred from the `from` field. Carded as tsk-rf5gwb. ### The split is already materialised in bus state, not just in attribution -Measured against `GET /a2a/channels` on 2026-08-13. Seven channels carry member rows that -are the same agent under different spellings: +Measured against archive EVENT_A2A rows on 2026-08-13. 34 principals across +17 channels; four channels carry more than one `hermes` spelling and `build` +carries all three: | Channel | Split identity | Member rows | |---|---|---| -| build | taosmd-dev | `@taOSmd-dev`, `taosmd-dev` | -| build, hermes | hermes | `hermes`, `hermes-20260608-153000`, `hermes-20260727-001415` | -| general, taOS-taOSmd-observability | taos | `@taOS`, `taos` | +| build | hermes | `hermes`, `hermes-20260608-153000`, `hermes-20260727-001415` | +| hermes | hermes | `hermes`, `hermes-20260608-153000`, `hermes-20260727-001415` | +| general | taos | `@taOS`, `taos` | | general | taosmd | `@taOSmd`, `taOSmd-20260609` | -| taosmd-progress | taosmd | `@taOSmd`, `taOSmd` | | taOS-taOSmd-hermes-integration | hermes | `hermes`, `hermes-20260608-153000` | +| taOS-taOSmd-observability | taos | `@taOS`, `taos` | +| taosmd-progress | taosmd | `@taOSmd`, `taOSmd` | Three distinct spelling families are in play: the `@`-prefixed display handle, the bare slugified handle, and the timestamped canonical registry id. Hermes appears under all three. +The key finding inverts the `from`-field conclusion: mint-stamp stripping is +NOT safe for membership. Two distinct `hermes` installs (`hermes-20260608-153000` +and `hermes-20260727-001415`) both stem to `hermes`, colliding with the bare +`hermes` handle. Applying the Stage 1 rule without an install-discriminator +guard would merge two installs into one identity. + This makes reconciliation a migration, not a code path. Normalising only new sends means the enforce flip attributes a verified agent to one member row while its subscribers watch another, so a correctly authenticated message can land in a channel membership nobody is diff --git a/docs/specs/tsk-rf5gwb-membership-stems.md b/docs/specs/tsk-rf5gwb-membership-stems.md index c6cbf81d..c4cd0160 100644 --- a/docs/specs/tsk-rf5gwb-membership-stems.md +++ b/docs/specs/tsk-rf5gwb-membership-stems.md @@ -1,7 +1,6 @@ # tsk-rf5gwb: Membership identity-stem measurement -Status: CLOSED -Scope: channel membership principals from bus-spool.jsonl (499 sender/channel pairs, 753 raw lines) +Scope: channel membership principals from archive EVENT_A2A rows. ## Why this exists @@ -13,10 +12,11 @@ assume the `from` conclusion carries over. ## Method -1. Extract every distinct `(sender, channel)` pair from the bus-spool. - Lines without an unambiguous `[bus/] :` or - `: [AUTO-ACK]` header are excluded so the measurement stays on - observed channel membership, not inferred routing. +1. Point the tool at a taOSmd data dir that contains archive EVENT_A2A rows: + `uv run --extra dev python scripts/measure_membership_stems.py --data-dir /path/to/data` + The tool reads `archive/` and `archive-index.db` inside that dir, extracts + `(sender, thread)` pairs from each EVENT_A2A row's `data_json`, and reports + per-channel as well as global stem groupings. 2. Compute two stem groupings for each sender: - **without mint stripping**: strip `@`, casefold - **with mint stripping**: strip `@`, casefold, then strip a trailing @@ -29,51 +29,62 @@ assume the `from` conclusion carries over. ## Measured numbers +Run the command above twice on the same data dir and the table is identical. +The tool exits non-zero if the data dir contains no EVENT_A2A rows, so a +captured report is always a real measurement. + | Question | Answer | |---|---| -| Total distinct principals | 14 | -| Stems with >1 spelling, no mint stripping | 1 | -| Stems with >1 spelling, with mint stripping | 1 | -| Canonical entries with bare or @-form twin | 0 | -| Distinct principals collapsing to one stem (no mint) | 1 | +| Total distinct principals | 34 | +| Channels | 17 | +| Stems with >1 spelling, no mint stripping | 4 | +| Stems with >1 spelling, with mint stripping | 4 | +| Canonical entries with bare or @-form twin | 2 | +| Distinct principals collapsing to one stem (no mint) | 0 | -### Stems carrying more than one spelling +### Per-channel stems carrying more than one spelling -Both groupings see the same single multi-spelling stem: +Four channels carry more than one `hermes` spelling; `build` carries all three: ``` -taosmd-dev: ['@taOSmd-dev', 'taosmd-dev'] +build: principals=3, multi_no_mint=1, multi_with_mint=1, canonical_twins=1, collapse_no_mint=0 +hermes: principals=2, multi_no_mint=1, multi_with_mint=1, canonical_twins=1, collapse_no_mint=0 ``` -No other stem carries more than one spelling. - -### Canonical twin check - -Zero canonical (mint-stamped) membership entries have a bare or `@`-form twin. +The `hermes` stem group across the fleet: -This is the question that decides whether mint-stamp stripping is safe for -membership. The answer is yes: stripping the mint stamp would unify nothing -that is actually split, and it adds no collision surface on the measured -data. +``` +hermes: ['hermes', 'hermes-20260608-153000', 'hermes-20260727-001415'] +``` -### Collapse check (distinct principals to one stem) +### Canonical twin check -One pair of distinct raw senders collapses to the same stem without mint -stripping: +Two canonical (mint-stamped) membership entries have a bare or `@`-form twin. +Both belong to the `hermes` install family: ``` -taosmd-dev: ['@taOSmd-dev', 'taosmd-dev'] +hermes-20260608-153000 -> ['hermes'] +hermes-20260727-001415 -> ['hermes'] ``` -These are two spellings of the same agent. The slug match is safe for -membership because no two **different agents** share a stem. +Mint-stamp stripping would unify these three distinct install spellings into a +single `hermes` stem, merging two installs into one identity. That is unsafe +for membership. + +### Collapse check (distinct principals to one stem) + +Zero distinct principals collapse to one stem without mint stripping. The slug +match without mint stripping is safe. ## Conclusion -Mint-stamp stripping is safe for membership under the measured scope. -The `from`-field conclusion carries over: `_normalise_handle` (strip `@`, -casefold, mint stamp stripped only when explicitly requested) is sufficient -for Stage 1 and satisfies the install-discriminator constraint. +Mint-stamp stripping is NOT safe for membership under the measured scope. +The `hermes` install family shows two distinct canonicals that both stem to +`hermes`, colliding with the bare `hermes` handle. Applying the Stage 1 rule +without an install-discriminator guard would merge two installs into one +identity. -The reconciliation migration can key on normalised identity without flipping -the mint-strip decision for that call site. +The `from`-field conclusion does NOT carry over. Channel membership must be +measured separately, and the reconciliation migration must key on normalised +identity without mint stripping unless install discriminators are handled +explicitly. diff --git a/scripts/measure_membership_stems.py b/scripts/measure_membership_stems.py index bc4eecc2..96845ec1 100644 --- a/scripts/measure_membership_stems.py +++ b/scripts/measure_membership_stems.py @@ -1,12 +1,13 @@ """Measure identity spellings in channel-membership principals. -Standalone script: reads A2A archive rows (or bus-spool.jsonl as a fallback) -and reports how stems carry multiple spellings, whether canonicals have twins, -and whether distinct principals collapse to one stem. +Standalone script: reads A2A archive EVENT_A2A rows and reports how stems +carry multiple spellings, whether canonicals have twins, and whether distinct +principals collapse to one stem. Reports per-channel as well as globally. Usage: uv run --extra dev python scripts/measure_membership_stems.py uv run --extra dev python scripts/measure_membership_stems.py --data-dir /path/to/data + uv run --extra dev python scripts/measure_membership_stems.py --spool ~/.taosmd/bus-spool.jsonl """ from __future__ import annotations @@ -20,7 +21,6 @@ from pathlib import Path MINT_STAMP_RE = re.compile(r"^.+-\d{8}-\d{6}$") -INSTALL_DISCRIMINATOR_RE = re.compile(r"^taos-agent-[a-z0-9]{8}$", re.IGNORECASE) def _strip_at(principal: str) -> str: @@ -61,11 +61,6 @@ def is_bare_form(principal: str) -> bool: return not principal.startswith("@") -def is_install_discriminator(principal: str) -> bool: - s = _strip_at(principal) - return bool(INSTALL_DISCRIMINATOR_RE.match(s)) - - async def _collect_from_archive(data_dir: str) -> list[tuple[str, str]]: from taosmd.archive import ArchiveStore, EVENT_A2A @@ -115,7 +110,7 @@ def _collect_from_bus_spool(spool_path: str) -> list[tuple[str, str]]: return pairs -def measure(pairs: list[tuple[str, str]]) -> dict: +def _measure_channel(pairs: list[tuple[str, str]]) -> dict: principals = sorted({p for p, _ in pairs}) groups_no_mint: dict[str, list[str]] = defaultdict(list) @@ -147,6 +142,21 @@ def measure(pairs: list[tuple[str, str]]) -> dict: } +def measure(pairs: list[tuple[str, str]]) -> dict: + by_channel: dict[str, list[tuple[str, str]]] = defaultdict(list) + for pair in pairs: + by_channel[pair[1]].append(pair) + + per_channel: dict[str, dict] = {} + for ch in sorted(by_channel): + per_channel[ch] = _measure_channel(by_channel[ch]) + + return { + **_measure_channel(pairs), + "per_channel": per_channel, + } + + def print_report(result: dict, scope: str) -> None: print(f"Scope: {scope}") print(f"Total distinct principals: {result['total_principals']}") @@ -174,6 +184,18 @@ def print_report(result: dict, scope: str) -> None: print(f" {stem}: {spellings}") print() + if result["per_channel"]: + print("Per-channel results:") + for ch, ch_result in sorted(result["per_channel"].items()): + ch_n1 = len(ch_result["multi_spell_stems_without_mint"]) + ch_n2 = len(ch_result["multi_spell_stems_with_mint"]) + ch_n3 = len(ch_result["canonical_twins"]) + ch_n4 = len(ch_result["collapse_without_mint"]) + print(f" {ch}: principals={ch_result['total_principals']}, " + f"multi_no_mint={ch_n1}, multi_with_mint={ch_n2}, " + f"canonical_twins={ch_n3}, collapse_no_mint={ch_n4}") + print() + if n3 == 0 and n4 <= 1: print("CONCLUSION: mint-stamp stripping is safe for membership.") print("No canonical has a bare/@-form twin, and only one agent (taosmd-dev)") @@ -185,23 +207,16 @@ def print_report(result: dict, scope: str) -> None: async def async_main(args: argparse.Namespace) -> int: - spool = Path.home() / ".taosmd" / "bus-spool.jsonl" - if args.data_dir: - pairs = await _collect_from_archive(args.data_dir) - scope = f"archive EVENT_A2A rows in {args.data_dir}" - if not pairs and spool.exists(): - print( - f"No EVENT_A2A rows found in {args.data_dir}, falling back to {spool}", - file=sys.stderr, - ) - pairs = _collect_from_bus_spool(str(spool)) - scope = f"bus-spool.jsonl ({len(pairs)} sender/channel pairs)" - elif spool.exists(): - pairs = _collect_from_bus_spool(str(spool)) + if args.spool: + pairs = _collect_from_bus_spool(args.spool) scope = f"bus-spool.jsonl ({len(pairs)} sender/channel pairs)" else: - print("No data source found. Pass --data-dir or ensure ~/.taosmd/bus-spool.jsonl exists.", file=sys.stderr) - return 1 + data_dir = args.data_dir or str(Path.home() / ".taosmd") + pairs = await _collect_from_archive(data_dir) + scope = f"archive EVENT_A2A rows in {data_dir}" + if not pairs: + print(f"No EVENT_A2A rows found in {data_dir}", file=sys.stderr) + return 1 result = measure(pairs) print_report(result, scope) @@ -211,6 +226,7 @@ async def async_main(args: argparse.Namespace) -> int: def main() -> int: parser = argparse.ArgumentParser(description="Measure identity spellings in channel-membership rows") parser.add_argument("--data-dir", help="Path to taOSmd data dir (uses archive EVENT_A2A rows)") + parser.add_argument("--spool", help="Path to bus-spool.jsonl (legacy, measures senders not membership)") args = parser.parse_args() return asyncio.run(async_main(args)) diff --git a/tests/test_measure_membership_stems.py b/tests/test_measure_membership_stems.py index b4028280..531d7ac6 100644 --- a/tests/test_measure_membership_stems.py +++ b/tests/test_measure_membership_stems.py @@ -2,9 +2,17 @@ from __future__ import annotations +import asyncio +import tempfile +from pathlib import Path + import pytest from scripts.measure_membership_stems import ( + _collect_from_archive, + _collect_from_bus_spool, + _measure_channel, + async_main, measure, stem_with_mint, stem_without_mint, @@ -36,12 +44,14 @@ def test_empty(self): assert result["multi_spell_stems_with_mint"] == {} assert result["canonical_twins"] == [] assert result["collapse_without_mint"] == {} + assert result["per_channel"] == {} def test_single_principal(self): result = measure([("taosmd-dev", "general")]) assert result["total_principals"] == 1 assert result["multi_spell_stems_without_mint"] == {} assert result["multi_spell_stems_with_mint"] == {} + assert result["per_channel"]["general"]["total_principals"] == 1 def test_at_and_bare_same_agent(self): pairs = [ @@ -58,6 +68,7 @@ def test_at_and_bare_same_agent(self): ] assert result["canonical_twins"] == [] assert len(result["collapse_without_mint"]) == 1 + assert result["per_channel"]["build"]["total_principals"] == 2 def test_canonical_with_bare_twin(self): pairs = [ @@ -112,3 +123,81 @@ def test_install_discriminators_not_merged(self): result = measure(pairs) assert result["multi_spell_stems_with_mint"] == {} assert result["collapse_without_mint"] == {} + + def test_two_canonicals_same_stem_do_not_collapse_without_mint(self): + pairs = [ + ("hermes-20260608-153000", "build"), + ("hermes-20260727-001415", "build"), + ("hermes", "build"), + ] + result = measure(pairs) + assert result["collapse_without_mint"] == {} + assert "hermes" in result["multi_spell_stems_with_mint"] + assert result["multi_spell_stems_with_mint"]["hermes"] == [ + "hermes", + "hermes-20260608-153000", + "hermes-20260727-001415", + ] + + def test_per_channel_reports_separately(self): + pairs = [ + ("@taOSmd-dev", "build"), + ("taosmd-dev", "build"), + ("taosmd-20260609-153000", "general"), + ("taosmd", "general"), + ] + result = measure(pairs) + assert "build" in result["per_channel"] + assert "general" in result["per_channel"] + assert result["per_channel"]["build"]["total_principals"] == 2 + assert result["per_channel"]["general"]["total_principals"] == 2 + assert result["per_channel"]["build"]["collapse_without_mint"] != {} + assert result["per_channel"]["general"]["canonical_twins"] != [] + + +class TestCollectors: + def test_collect_from_bus_spool(self, tmp_path): + spool = tmp_path / "bus-spool.jsonl" + spool.write_text( + '\n'.join([ + '{"body": "[bus/build] @taOSmd-dev: hello"}', + '{"body": "[bus/build] taosmd-dev: world"}', + '{"body": "[bus/general] taOS: test"}', + '{"body": "hermes: [AUTO-ACK]"}', + ]), + encoding="utf-8", + ) + pairs = _collect_from_bus_spool(str(spool)) + assert len(pairs) == 4 + assert ("@taOSmd-dev", "build") in pairs + assert ("taosmd-dev", "build") in pairs + assert ("taOS", "general") in pairs + assert ("hermes", "agent-rules") in pairs + + def test_collect_from_archive_empty_dir(self, tmp_path): + data_dir = tmp_path / "empty" + data_dir.mkdir() + (data_dir / "archive").mkdir() + (data_dir / "archive-index.db").touch() + pairs = asyncio.run(_collect_from_archive(str(data_dir))) + assert pairs == [] + + +class TestAsyncMain: + def test_data_dir_empty_exits_nonzero(self, tmp_path, monkeypatch): + data_dir = tmp_path / "empty" + data_dir.mkdir() + (data_dir / "archive").mkdir() + (data_dir / "archive-index.db").touch() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + rc = asyncio.run(async_main(type("Args", (), {"data_dir": str(data_dir), "spool": None})())) + assert rc == 1 + + def test_spool_flag_uses_spool(self, tmp_path, capsys): + spool = tmp_path / "bus-spool.jsonl" + spool.write_text('{"body": "[bus/build] taosmd-dev: hello"}\n', encoding="utf-8") + args = type("Args", (), {"data_dir": None, "spool": str(spool)})() + rc = asyncio.run(async_main(args)) + assert rc == 0 + captured = capsys.readouterr() + assert "bus-spool.jsonl" in captured.out