sm-resolver 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ """sm-resolver — the source-agnostic corroboration kernel.
2
+
3
+ A tiny, dependency-free kernel for detecting when independent sources disagree
4
+ about the same subject. It is the machinery under cross-registry / cross-method
5
+ divergence detection, factored out so any layer can reuse it:
6
+
7
+ - ``View`` — the contract a claim implements (``comparable() → named fields``).
8
+ - ``Resolver[T]`` — a per-source adapter: canonical id → ``(Status, View)``.
9
+ - ``diff_views`` — the pure diff: per-source claims → ``Finding`` list
10
+ (``omission`` + one per disagreeing field). It never learns its layer.
11
+ - ``Corroborator`` — resolve every source, diff, emit (deduped).
12
+
13
+ A consumer supplies a view type (its fields are what gets compared) and thin
14
+ per-source resolvers; the kernel does the rest, identically across discovery,
15
+ identity, capability, or evidence layers. See ``sm-divergence`` for the reference
16
+ layers (registries, DID methods) built on this.
17
+
18
+ Zero runtime dependencies.
19
+ """
20
+
21
+ from .corroborate import Corroborator, OnFinding
22
+ from .diff import diff_views
23
+ from .models import OMISSION, Finding
24
+ from .resolver import Resolver, Status
25
+ from .view import View, ViewT
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "OMISSION",
31
+ "Corroborator",
32
+ "Finding",
33
+ "OnFinding",
34
+ "Resolver",
35
+ "Status",
36
+ "View",
37
+ "ViewT",
38
+ "diff_views",
39
+ ]
@@ -0,0 +1,77 @@
1
+ """Generic orchestration: resolve every watched id against every source, diff,
2
+ and hand new findings to a callback. Layer-agnostic — a thin adapter for any
3
+ source set (e.g. two NANDA Indexes, or a NEST + an Index) drives this directly
4
+ with its own ``Resolver`` instances; no discovery internals required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable, Iterable
10
+ from typing import Generic
11
+
12
+ from .diff import diff_views
13
+ from .models import Finding
14
+ from .resolver import Resolver
15
+ from .view import ViewT
16
+
17
+ OnFinding = Callable[[Finding], None]
18
+
19
+
20
+ class Corroborator(Generic[ViewT]):
21
+ """Corroborate a set of sources for a watch set.
22
+
23
+ Give it two or more ``Resolver`` instances; each ``check`` asks all of them
24
+ the same by-id questions and reports any disagreement. Fewer than two
25
+ sources → nothing to corroborate → no-op. Sources are deduped by ``label``.
26
+
27
+ ``on_finding`` fires once per distinct finding across the corroborator's
28
+ lifetime (deduped by fingerprint); ``check`` always returns ALL current
29
+ findings. Never raises.
30
+ """
31
+
32
+ def __init__(self, resolvers: Iterable[Resolver[ViewT]], *, on_finding: OnFinding | None = None) -> None:
33
+ deduped: list[Resolver[ViewT]] = []
34
+ seen: set[str] = set()
35
+ for r in resolvers:
36
+ if r.label in seen:
37
+ continue
38
+ seen.add(r.label)
39
+ deduped.append(r)
40
+ self.resolvers = deduped
41
+ self.on_finding = on_finding
42
+ self._emitted: set[str] = set()
43
+
44
+ @property
45
+ def labels(self) -> list[str]:
46
+ return [r.label for r in self.resolvers]
47
+
48
+ async def check(self, watch_ids: Iterable[str]) -> list[Finding]:
49
+ ids = sorted(set(watch_ids))
50
+ if len(self.resolvers) < 2 or not ids:
51
+ return []
52
+
53
+ views: dict[str, dict[str, ViewT | None]] = {}
54
+ for resolver in self.resolvers:
55
+ per_source: dict[str, ViewT | None] = {}
56
+ for aid in ids:
57
+ try:
58
+ status, view = await resolver.resolve(aid)
59
+ except Exception:
60
+ status, view = "error", None
61
+ if status == "error":
62
+ continue # unreachable is not a claim
63
+ per_source[aid] = view
64
+ views[resolver.label] = per_source
65
+
66
+ findings = diff_views(views, ids)
67
+ if self.on_finding is not None:
68
+ for f in findings:
69
+ fp = f.fingerprint()
70
+ if fp in self._emitted:
71
+ continue
72
+ self._emitted.add(fp)
73
+ try:
74
+ self.on_finding(f)
75
+ except Exception:
76
+ pass
77
+ return findings
sm_resolver/diff.py ADDED
@@ -0,0 +1,71 @@
1
+ """The pure diff — no I/O, generic over the view type. Given each source's view
2
+ of each id, find the disagreements. This is the whole idea; everything else is
3
+ plumbing to feed it, and it never learns what layer it is serving.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Iterable, Mapping
9
+
10
+ from .models import OMISSION, Finding
11
+ from .view import ViewT
12
+
13
+
14
+ def diff_views(
15
+ views: Mapping[str, Mapping[str, ViewT | None]],
16
+ watch_ids: Iterable[str],
17
+ ) -> list[Finding]:
18
+ """Compare what every source claims about each watched id.
19
+
20
+ ``views[source][agent_id]`` is:
21
+ - a view — the source served a claim;
22
+ - ``None`` — the source POSITIVELY reports the id absent (a 404);
23
+ - simply *missing from the inner mapping* — the source made no claim
24
+ (unreachable). A silent source is never present or absent: a timeout is
25
+ not evidence.
26
+
27
+ Findings, per id:
28
+ - ``omission`` — present on ≥1 source AND confirmed-absent on ≥1 other.
29
+ - one per view field (``endpoint``, ``did``, ``key``, …) whose non-``None``
30
+ values disagree across the sources that served it.
31
+
32
+ Deterministic and side-effect free; never raises.
33
+ """
34
+ findings: list[Finding] = []
35
+ for aid in sorted(set(watch_ids)):
36
+ present: dict[str, Mapping[str, str | None]] = {}
37
+ confirmed_absent: list[str] = []
38
+ for source, per_source in views.items():
39
+ if aid not in per_source:
40
+ continue # no claim — unreachable, not an omission
41
+ claim = per_source[aid]
42
+ if claim is None:
43
+ confirmed_absent.append(source)
44
+ else:
45
+ present[source] = claim.comparable()
46
+
47
+ if present and confirmed_absent:
48
+ findings.append(
49
+ Finding(
50
+ OMISSION,
51
+ aid,
52
+ {"present_on": sorted(present), "missing_from": sorted(confirmed_absent)},
53
+ )
54
+ )
55
+
56
+ field_names: list[str] = []
57
+ for comparable in present.values():
58
+ for name in comparable:
59
+ if name not in field_names:
60
+ field_names.append(name)
61
+
62
+ for name in field_names:
63
+ values: dict[str, str] = {}
64
+ for source, comparable in present.items():
65
+ value = comparable.get(name)
66
+ if value is not None:
67
+ values[source] = value
68
+ if len(set(values.values())) > 1:
69
+ findings.append(Finding(name, aid, {"field": name, "values": dict(sorted(values.items()))}))
70
+
71
+ return findings
sm_resolver/models.py ADDED
@@ -0,0 +1,38 @@
1
+ """The finding — what disagreement was found. Layer-agnostic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+
8
+ # The universal divergence kind (present on one source, confirmed-absent on
9
+ # another). Field-level kinds are named after the view field that diverged
10
+ # (e.g. "endpoint", "did", "key") and are supplied by the view, not fixed here.
11
+ OMISSION = "omission"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Finding:
16
+ """One divergence about one subject across the compared sources.
17
+
18
+ ``kind`` is ``omission`` or the name of a view field that diverged.
19
+ ``detail`` carries the contradicting per-source claims. For ``omission`` it
20
+ is ``{present_on, missing_from}``; for a field divergence it is
21
+ ``{field, values}`` (``values`` keyed by source label) — one uniform shape
22
+ across every layer, so any consumer parses findings the same way.
23
+ """
24
+
25
+ kind: str
26
+ agent_id: str
27
+ detail: dict[str, object] = field(default_factory=dict)
28
+
29
+ def fingerprint(self) -> str:
30
+ """A stable key for deduping a persisting finding across repeated
31
+ checks — same disagreement, same fingerprint."""
32
+ return json.dumps(
33
+ {"kind": self.kind, "agent_id": self.agent_id, "detail": self.detail},
34
+ sort_keys=True,
35
+ )
36
+
37
+ def to_dict(self) -> dict[str, object]:
38
+ return {"kind": self.kind, "agent_id": self.agent_id, "detail": self.detail}
sm_resolver/py.typed ADDED
File without changes
@@ -0,0 +1,37 @@
1
+ """The Resolver protocol — one registry/source, one way of being asked.
2
+
3
+ Sources do not share a wire format: a NEST server answers ``GET /api/agents/{id}``;
4
+ a NANDA Index answers a two-hop resolve; a DID method resolves a document; a
5
+ DNS-AID zone answers a TXT lookup. A ``Resolver`` hides that difference: given a
6
+ canonical agent id it performs *that* source's query, applies *that* source's
7
+ native verification, and normalizes the answer to ``(Status, View)`` — so the
8
+ diff downstream is untouched by format heterogeneity. This is the seam a thin
9
+ per-source adapter (e.g. a NANDA Index adapter) implements.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Literal, Protocol, TypeVar
15
+
16
+ from .view import View
17
+
18
+ # One source's claim about one id: served / positively-absent / no-claim.
19
+ Status = Literal["present", "absent", "error"]
20
+
21
+ # Covariant: a Resolver only ever PRODUCES its view type, so Resolver[Sub] is
22
+ # usable where Resolver[Base] is expected.
23
+ T_co = TypeVar("T_co", bound=View, covariant=True)
24
+
25
+
26
+ class Resolver(Protocol[T_co]):
27
+ """Resolves a canonical agent id to one source's normalized claim.
28
+
29
+ ``label`` names the source in findings. ``resolve`` MUST NOT raise — an
30
+ unreachable or unparseable source is the ``"error"`` claim (no claim),
31
+ never a false ``"absent"``.
32
+ """
33
+
34
+ @property
35
+ def label(self) -> str: ...
36
+
37
+ async def resolve(self, agent_id: str) -> tuple[Status, T_co | None]: ...
sm_resolver/view.py ADDED
@@ -0,0 +1,28 @@
1
+ """The comparable-view contract — the one thing a layer must provide.
2
+
3
+ A view is any object that reduces itself to a set of named string fields via
4
+ ``comparable()``. Those fields ARE what corroboration compares: each field whose
5
+ value differs across sources becomes a divergence finding named for that field;
6
+ a field value of ``None`` never participates (an unverifiable value is not a
7
+ disagreement). Omission (present vs confirmed-absent) is universal and needs no
8
+ field.
9
+
10
+ This is the whole contract an adapter author implements — return a view type
11
+ from a ``Resolver``, give it a ``comparable()``, and the generic diff does the
12
+ rest, identically across discovery, identity, capability, and evidence layers.
13
+ The discovery layer's ``RecordView`` is just the first implementation.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Mapping
19
+ from typing import Protocol, TypeVar
20
+
21
+
22
+ class View(Protocol):
23
+ """A claim reduced to named, comparable string fields."""
24
+
25
+ def comparable(self) -> Mapping[str, str | None]: ...
26
+
27
+
28
+ ViewT = TypeVar("ViewT", bound=View)
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: sm-resolver
3
+ Version: 0.1.0
4
+ Summary: The source-agnostic corroboration kernel — Resolver[T], the View contract, a pure diff, and the Corroborator. Zero runtime dependencies.
5
+ Project-URL: Homepage, https://github.com/Sharathvc23/sm-resolver
6
+ Project-URL: Spec, https://github.com/Sharathvc23/sm-resolver/blob/main/SPEC.md
7
+ Project-URL: Whitepaper, https://github.com/Sharathvc23/sm-resolver/blob/main/WHITEPAPER.md
8
+ Author: Sharath (Stellarminds)
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: accountability,ai-agents,corroboration,divergence,nanda,resolver
12
+ Requires-Python: >=3.11
13
+ Provides-Extra: dev
14
+ Requires-Dist: mypy>=1.10; extra == 'dev'
15
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
16
+ Requires-Dist: pytest>=8; extra == 'dev'
17
+ Requires-Dist: ruff>=0.6; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # sm-resolver — the corroboration kernel
21
+
22
+ **A tiny, dependency-free kernel for detecting when independent sources disagree
23
+ about the same subject.** Point it at two or more sources, ask them the same
24
+ question, and it reports any disagreement. It is the machinery under
25
+ cross-registry / cross-method divergence detection, factored out so any layer can
26
+ reuse it.
27
+
28
+ Four pieces, and only the resolvers know a wire format:
29
+
30
+ - **`View`** — the contract a claim implements: `comparable() → {field: value}`.
31
+ Those fields are what gets compared; a `None` value never participates.
32
+ - **`Resolver[T]`** — a per-source adapter: a canonical id → `(Status, View)`.
33
+ It hides one source's format (an HTTP GET, a DID resolve, a DNS lookup) and
34
+ MUST NOT raise — an unreachable source is `error` (no claim), never a false
35
+ `absent`.
36
+ - **`diff_views`** — the pure diff: per-source claims → `Finding` list. It emits
37
+ `omission` (present on one source, positively absent on another) and one
38
+ finding per view field whose values disagree. It never learns its layer.
39
+ - **`Corroborator`** — resolve every source, diff, and emit new findings once
40
+ (deduped). Fewer than two sources → no-op.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install sm-resolver # zero runtime dependencies
46
+ ```
47
+
48
+ ## Use
49
+
50
+ Supply a view (its fields are what you compare) and a thin resolver per source:
51
+
52
+ ```python
53
+ import asyncio
54
+ from collections.abc import Mapping
55
+ from dataclasses import dataclass
56
+ from sm_resolver import Corroborator, Status
57
+
58
+ @dataclass(frozen=True)
59
+ class RecordView: # your layer's view
60
+ endpoint: str | None = None
61
+ def comparable(self) -> Mapping[str, str | None]:
62
+ return {"endpoint": self.endpoint}
63
+
64
+ class MyResolver: # your thin per-source adapter
65
+ def __init__(self, label): self.label = label
66
+ async def resolve(self, agent_id) -> tuple[Status, RecordView | None]:
67
+ ... # query this source; normalize to RecordView
68
+
69
+ findings = asyncio.run(
70
+ Corroborator([MyResolver("a"), MyResolver("b")]).check(["agent-1"])
71
+ )
72
+ for f in findings:
73
+ print(f.kind, f.agent_id, f.detail)
74
+ # endpoint agent-1 {'field': 'endpoint', 'values': {'a': 'https://real', 'b': 'https://evil'}}
75
+ ```
76
+
77
+ Long-running? Pass `on_finding=` — it fires once per distinct finding across the
78
+ corroborator's lifetime.
79
+
80
+ ## Who uses it
81
+
82
+ [`sm-divergence`](https://github.com/Sharathvc23/sm-divergence) builds the
83
+ reference layers on this kernel: a **discovery** layer (registries — NEST, the
84
+ NANDA Index) and an **identity** layer (DID methods — `did:key`, `did:web`,
85
+ Universal Resolver), each supplying its own view and thin resolvers. Capability
86
+ and evidence layers are the same shape.
87
+
88
+ ## Design rules
89
+
90
+ - **A timeout is not a claim.** An unreachable or erroring source is excluded,
91
+ never counted as an omission.
92
+ - **Only present, comparable values disagree.** A `None` field contributes
93
+ nothing — an unverifiable value is not a disagreement.
94
+ - **The diff stays pure and generic** — deterministic, no I/O, never raises,
95
+ never learns its layer. All I/O lives in the resolvers.
96
+
97
+ ## Develop
98
+
99
+ ```bash
100
+ make ci-local # uv: sync → ruff → format → mypy --strict → pytest
101
+ ```
102
+
103
+ ## License
104
+
105
+ [MIT](LICENSE)
106
+
107
+ ---
108
+
109
+ *First published: 2026-07-04 | Last modified: 2026-07-04*
110
+
111
+ *Personal research contributions aligned with [Project NANDA](https://projectnanda.org) standards. [Stellarminds.ai](https://stellarminds.ai)*
@@ -0,0 +1,11 @@
1
+ sm_resolver/__init__.py,sha256=iOjfO6iAufXEbjrdabmKXEx3GNfIJw4X69NSSWbZoa8,1347
2
+ sm_resolver/corroborate.py,sha256=JuF6rhxYzM_Cwbze3N4YUp0SX17bWRlfJKuNAqAHlug,2739
3
+ sm_resolver/diff.py,sha256=sXS2rQCt3yqqHNbJFL4yRFI9Z_DmKVW_M61ALTqVWHI,2640
4
+ sm_resolver/models.py,sha256=E2SlnHIok2XFaKeNEswectY8l9royeRlEJK80MudcYA,1440
5
+ sm_resolver/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ sm_resolver/resolver.py,sha256=g825OkEQ8cTnpMPBFkcVYRQBnFTEfh-15CdckdCnvV4,1444
7
+ sm_resolver/view.py,sha256=oxIkWKM5Jk08ZO4TTdMSGZAcjbBesnuttRI5tFTI5mM,1082
8
+ sm_resolver-0.1.0.dist-info/METADATA,sha256=9xVcErd1POy9Y2R5R3vHv99sVYToQFLNdG3--B4htOc,4326
9
+ sm_resolver-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ sm_resolver-0.1.0.dist-info/licenses/LICENSE,sha256=mo4bEHboox6Cvk1DuRHhTstvfo1NRFPs5QqRfvrufBc,1072
11
+ sm_resolver-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stellarminds.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.