aetheridentity 2.0.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,178 @@
1
+ """L3 — Reputation (§6.3 of SPECIFICATION.md).
2
+
3
+ Implements Definition 3 of the paper:
4
+ - Reputation record: signed observation.
5
+ - Score: fixed point of an EigenTrust-style iteration, computed in exact
6
+ arithmetic with a fixed iteration count K and a canonical ordering.
7
+ - The score is a view over S, not a stored value.
8
+
9
+ Out of scope (v2.0.0):
10
+ - Whether reputation may influence admission (policy decision).
11
+ - Per-task-class reputation (open question Q10).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from fractions import Fraction
17
+ from typing import Iterable
18
+
19
+ from aetheridentity.records import (
20
+ ReputationRecord,
21
+ canonical_bytes,
22
+ )
23
+ from aetheridentity.signer import Signer
24
+
25
+ K = 100
26
+ DAMPING = Fraction(1, 5) # a = 0.2
27
+
28
+
29
+ def record_observation(
30
+ signer: Signer,
31
+ observer: str,
32
+ subject: str,
33
+ evidence: str,
34
+ outcome: int,
35
+ created_at: int = 0,
36
+ ) -> ReputationRecord:
37
+ """Create and sign a reputation record."""
38
+ if outcome not in (-1, 0, 1):
39
+ raise ValueError("outcome must be -1, 0, or +1")
40
+ record = ReputationRecord(
41
+ record_type="reputation",
42
+ signer_id=observer,
43
+ created_at=created_at,
44
+ observer=observer,
45
+ subject=subject,
46
+ evidence=evidence,
47
+ outcome=outcome,
48
+ t=created_at,
49
+ )
50
+ sig = signer.sign(canonical_bytes(record))
51
+ return ReputationRecord(**{**record.__dict__, "signature": sig})
52
+
53
+
54
+ def _build_local_trust(
55
+ records: Iterable[ReputationRecord],
56
+ observers: list[str],
57
+ subjects: list[str],
58
+ ) -> list[list[Fraction]]:
59
+ """Build the row-stochastic matrix C from reputation records.
60
+
61
+ C[i][j] = normalized positive outcome from observers[i] about subjects[j].
62
+ """
63
+ n = len(observers)
64
+ m = len(subjects)
65
+ raw = [[0 for _ in range(m)] for _ in range(n)]
66
+
67
+ # Accumulate positive outcomes only (v > 0), as in the paper's definition.
68
+ for r in records:
69
+ if r.record_type != "reputation":
70
+ continue
71
+ if r.observer not in observers or r.subject not in subjects:
72
+ continue
73
+ i = observers.index(r.observer)
74
+ j = subjects.index(r.subject)
75
+ if r.outcome > 0:
76
+ raw[i][j] += r.outcome
77
+
78
+ # Normalize each row. If a row is all zeros, use the uniform distribution.
79
+ C = [[Fraction(0) for _ in range(m)] for _ in range(n)]
80
+ for i in range(n):
81
+ total = sum(raw[i])
82
+ if total == 0:
83
+ for j in range(m):
84
+ C[i][j] = Fraction(1, m)
85
+ else:
86
+ for j in range(m):
87
+ C[i][j] = Fraction(raw[i][j], total)
88
+ return C
89
+
90
+
91
+ def _anchor_distribution(anchors: list[str], subjects: list[str]) -> list[Fraction]:
92
+ """Uniform distribution over the anchor set Γ₀."""
93
+ m = len(subjects)
94
+ if not anchors:
95
+ return [Fraction(1, m) for _ in range(m)]
96
+ p = [Fraction(0) for _ in range(m)]
97
+ for a in anchors:
98
+ if a in subjects:
99
+ p[subjects.index(a)] = Fraction(1, len(anchors))
100
+ return p
101
+
102
+
103
+ def _iterate(
104
+ C: list[list[Fraction]],
105
+ p: list[Fraction],
106
+ a: Fraction,
107
+ K: int,
108
+ ) -> list[Fraction]:
109
+ """Iterate t ← (1 − a) · Cᵀ t + a · p for K steps.
110
+
111
+ Initial t is the uniform distribution.
112
+ """
113
+ m = len(p)
114
+ t = [Fraction(1, m) for _ in range(m)]
115
+ one_minus_a = Fraction(1) - a
116
+
117
+ for _ in range(K):
118
+ # Compute Cᵀ t: for each j, sum_i C[i][j] * t[i].
119
+ ct = [Fraction(0) for _ in range(m)]
120
+ for i in range(len(C)):
121
+ ti = t[i]
122
+ if ti == 0:
123
+ continue
124
+ row = C[i]
125
+ for j in range(m):
126
+ ct[j] += row[j] * ti
127
+ t = [one_minus_a * ct[j] + a * p[j] for j in range(m)]
128
+ return t
129
+
130
+
131
+ def reputation_of(
132
+ records: Iterable[ReputationRecord],
133
+ anchors: list[str] | None = None,
134
+ ) -> dict[str, Fraction]:
135
+ """Compute the reputation vector over all subjects in `records`.
136
+
137
+ - Observers and subjects are sorted canonically (lexicographic by id).
138
+ - Iteration count K = 100.
139
+ - Damping a = 1/5.
140
+ - Exact arithmetic (Fraction).
141
+ """
142
+ records = list(records)
143
+ observers_set = {r.observer for r in records if r.record_type == "reputation"}
144
+ subjects_set = {r.subject for r in records if r.record_type == "reputation"}
145
+ observers = sorted(observers_set)
146
+ subjects = sorted(subjects_set)
147
+
148
+ if not subjects:
149
+ return {}
150
+
151
+ C = _build_local_trust(records, observers, subjects)
152
+ p = _anchor_distribution(anchors or [], subjects)
153
+ t = _iterate(C, p, DAMPING, K)
154
+ return {subjects[j]: t[j] for j in range(len(subjects))}
155
+
156
+
157
+ def reputation_residual(
158
+ records: Iterable[ReputationRecord],
159
+ anchors: list[str] | None = None,
160
+ ) -> Fraction:
161
+ """‖T(t_K) − t_K‖₁, for the convergence check (T06)."""
162
+ records = list(records)
163
+ observers = sorted({r.observer for r in records if r.record_type == "reputation"})
164
+ subjects = sorted({r.subject for r in records if r.record_type == "reputation"})
165
+ if not subjects:
166
+ return Fraction(0)
167
+
168
+ C = _build_local_trust(records, observers, subjects)
169
+ p = _anchor_distribution(anchors or [], subjects)
170
+ t_K = _iterate(C, p, DAMPING, K)
171
+ # One more iteration.
172
+ m = len(p)
173
+ ct = [Fraction(0) for _ in range(m)]
174
+ for i in range(len(C)):
175
+ for j in range(m):
176
+ ct[j] += C[i][j] * t_K[i]
177
+ t_next = [(Fraction(1) - DAMPING) * ct[j] + DAMPING * p[j] for j in range(m)]
178
+ return sum(abs(t_next[j] - t_K[j]) for j in range(m))
@@ -0,0 +1,78 @@
1
+ """Abstract Signer interface (§4 of SPECIFICATION.md).
2
+
3
+ Two implementations:
4
+ - FakeSigner: for unit tests and scenarios. Not cryptographic.
5
+ - Ed25519Signer: real Ed25519 via the `cryptography` library.
6
+
7
+ A signature produced by one is not accepted by the other.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Protocol
13
+
14
+ from cryptography.exceptions import InvalidSignature
15
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
16
+ Ed25519PrivateKey,
17
+ Ed25519PublicKey,
18
+ )
19
+ from cryptography.hazmat.primitives.serialization import (
20
+ Encoding,
21
+ PublicFormat,
22
+ )
23
+
24
+
25
+ class Signer(Protocol):
26
+ """Abstract signer interface."""
27
+
28
+ def sign(self, message: bytes) -> bytes: ...
29
+ def verify(self, message: bytes, signature: bytes, public_key: bytes) -> bool: ...
30
+ def public_key(self) -> bytes: ...
31
+
32
+
33
+ class FakeSigner:
34
+ """Non-cryptographic signer for tests.
35
+
36
+ The signature is the SHA-256 of the message prefixed with a fixed tag.
37
+ This is NOT secure and MUST NOT be used outside tests.
38
+ """
39
+
40
+ TAG = b"FAKE-SIGNER:"
41
+
42
+ def __init__(self) -> None:
43
+ self._pk = b"fake-pk"
44
+
45
+ def sign(self, message: bytes) -> bytes:
46
+ import hashlib
47
+
48
+ return self.TAG + hashlib.sha256(message).digest()
49
+
50
+ def verify(self, message: bytes, signature: bytes, public_key: bytes) -> bool:
51
+ if not signature.startswith(self.TAG):
52
+ return False
53
+ return self.sign(message) == signature
54
+
55
+ def public_key(self) -> bytes:
56
+ return self._pk
57
+
58
+
59
+ class Ed25519Signer:
60
+ """Real Ed25519 signer using the `cryptography` library."""
61
+
62
+ def __init__(self) -> None:
63
+ self._sk = Ed25519PrivateKey.generate()
64
+ self._pk = self._sk.public_key()
65
+
66
+ def sign(self, message: bytes) -> bytes:
67
+ return self._sk.sign(message)
68
+
69
+ def verify(self, message: bytes, signature: bytes, public_key: bytes) -> bool:
70
+ try:
71
+ pub = Ed25519PublicKey.from_public_bytes(public_key)
72
+ pub.verify(signature, message)
73
+ return True
74
+ except (InvalidSignature, ValueError):
75
+ return False
76
+
77
+ def public_key(self) -> bytes:
78
+ return self._pk.public_bytes(Encoding.Raw, PublicFormat.Raw)
@@ -0,0 +1,49 @@
1
+ """Minimal independent simulator of replicated state S (§8 of SPECIFICATION.md).
2
+
3
+ S is a flat list of admitted records. Admission is sequential. No
4
+ concurrency, no causal delivery, no fork resolution.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from aetheridentity.records import AnyRecord, record_hash
10
+ from aetheridentity.signer import Signer
11
+
12
+
13
+ class Simulator:
14
+ """Replicated state S as a flat list of records."""
15
+
16
+ def __init__(self, signer: Signer) -> None:
17
+ self._signer = signer
18
+ self._records: list[AnyRecord] = []
19
+ self._hashes: set[str] = set()
20
+
21
+ def admit(self, record: AnyRecord) -> bool:
22
+ """Admit a record. Returns True if admitted, False if duplicate.
23
+
24
+ Raises:
25
+ ValueError: if the record's signature does not verify.
26
+ """
27
+ # 1. Structural validation is the responsibility of the record class
28
+ # (its __post_init__). A malformed record cannot be constructed.
29
+ # 2. Signature verification.
30
+ if record.signature:
31
+ from aetheridentity.records import canonical_bytes
32
+
33
+ if not self._signer.verify(canonical_bytes(record), record.signature, b""):
34
+ raise ValueError("signature verification failed")
35
+
36
+ # 3. Deduplication by canonical hash.
37
+ h = record_hash(record)
38
+ if h in self._hashes:
39
+ return False
40
+ self._hashes.add(h)
41
+ self._records.append(record)
42
+ return True
43
+
44
+ def records(self) -> list[AnyRecord]:
45
+ """Return a copy of S. The caller cannot mutate internal state."""
46
+ return list(self._records)
47
+
48
+ def __len__(self) -> int:
49
+ return len(self._records)
@@ -0,0 +1,119 @@
1
+ """L4 — Sybil Resistance (§6.4 of SPECIFICATION.md).
2
+
3
+ Implements:
4
+ - SybilClaim: signed, evidence-backed claim of shared control.
5
+ - Cluster formation: claims signed by at least tau distinct anchored attesters.
6
+ - cluster_weight_of: divide each member's weight by cluster size.
7
+ - detection_rates: separate false-positive and false-negative rates.
8
+
9
+ Out of scope (v2.0.0):
10
+ - Detection guarantees (none are claimed).
11
+ - Admissible evidence (open question Q13).
12
+ - Threshold tau (fixed at 2 in INVARIANTS.md).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Iterable
18
+
19
+ from aetheridentity.records import SybilClaim, canonical_bytes
20
+ from aetheridentity.signer import Signer
21
+
22
+ TAU = 2 # threshold for cluster formation (INVARIANTS.md, O4)
23
+
24
+
25
+ def issue_sybil_claim(
26
+ signer: Signer,
27
+ attester: str,
28
+ target: str,
29
+ evidence: str,
30
+ created_at: int = 0,
31
+ ) -> SybilClaim:
32
+ """Create and sign a Sybil claim."""
33
+ record = SybilClaim(
34
+ record_type="sybil_claim",
35
+ signer_id=attester,
36
+ created_at=created_at,
37
+ attester=attester,
38
+ target=target,
39
+ evidence=evidence,
40
+ t=created_at,
41
+ )
42
+ sig = signer.sign(canonical_bytes(record))
43
+ return SybilClaim(**{**record.__dict__, "signature": sig})
44
+
45
+
46
+ def _cluster_of(
47
+ records: Iterable[SybilClaim],
48
+ anchored: set[str],
49
+ tau: int = TAU,
50
+ ) -> dict[str, set[str]]:
51
+ """Build clusters from claims.
52
+
53
+ A cluster is a set of identities linked by at least `tau` distinct
54
+ anchored attesters. The evidence string is used as a grouping key:
55
+ claims about the same evidence are considered to describe the same
56
+ cluster.
57
+ """
58
+ # Group claims by evidence.
59
+ by_evidence: dict[str, list[SybilClaim]] = {}
60
+ for r in records:
61
+ if r.record_type != "sybil_claim":
62
+ continue
63
+ by_evidence.setdefault(r.evidence, []).append(r)
64
+
65
+ clusters: dict[str, set[str]] = {}
66
+ for evidence, claims in by_evidence.items():
67
+ distinct_attesters = {c.attester for c in claims if c.attester in anchored}
68
+ if len(distinct_attesters) < tau:
69
+ continue
70
+ members: set[str] = set()
71
+ for c in claims:
72
+ members.add(c.attester)
73
+ members.add(c.target)
74
+ for m in members:
75
+ clusters[m] = members
76
+ return clusters
77
+
78
+
79
+ def cluster_weight_of(
80
+ records: Iterable[SybilClaim],
81
+ ident: str,
82
+ anchored: set[str] | None = None,
83
+ tau: int = TAU,
84
+ ) -> float:
85
+ """Return the weight of `ident` in its cluster (1/m), or 1.0 if not in a cluster."""
86
+ anchored = anchored or set()
87
+ clusters = _cluster_of(records, anchored, tau)
88
+ if ident not in clusters:
89
+ return 1.0
90
+ return 1.0 / len(clusters[ident])
91
+
92
+
93
+ def detection_rates(
94
+ predicted: set[str],
95
+ ground_truth: set[str],
96
+ honest_operators: set[str],
97
+ ) -> dict[str, float]:
98
+ """Compute false-positive and false-negative rates.
99
+
100
+ - False positive rate: fraction of honest operators wrongly flagged.
101
+ - False negative rate: fraction of true Sybils not flagged.
102
+ - Detection rate: fraction of true Sybils flagged.
103
+ """
104
+ true_positives = predicted & ground_truth
105
+ false_positives = (predicted - ground_truth) & honest_operators
106
+ false_negatives = ground_truth - predicted
107
+
108
+ fp_rate = len(false_positives) / len(honest_operators) if honest_operators else 0.0
109
+ fn_rate = len(false_negatives) / len(ground_truth) if ground_truth else 0.0
110
+ detection_rate = len(true_positives) / len(ground_truth) if ground_truth else 0.0
111
+
112
+ return {
113
+ "detection_rate": detection_rate,
114
+ "false_positive_rate": fp_rate,
115
+ "false_negative_rate": fn_rate,
116
+ "true_positives": len(true_positives),
117
+ "false_positives": len(false_positives),
118
+ "false_negatives": len(false_negatives),
119
+ }