alter-runtime 0.3.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.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,196 @@
1
+ """Append-only read ledger for Atlas substrate reads.
2
+
3
+ Every adapter read (or dry-run) emits a ``LedgerEntry`` to the JSONL file at
4
+ ``~/.local/state/alter/atlas/reads.jsonl``. The ledger records *what was read,
5
+ when, under which consent grant, and the content-free hash of the resulting
6
+ signature*. It is the local surface the user can inspect with ``alter atlas
7
+ show`` and the source the Mirror renderer cites for observation timestamps.
8
+
9
+ Honesty clause - this ledger is a SOFT SIGNAL, not a cryptographic audit.
10
+
11
+ Per substrate spec §4.3: the egress-capable parent daemon writes this ledger.
12
+ When the parent daemon is the attacker, the ledger is an honest-witness weakness
13
+ - the daemon can simply omit or rewrite entries. Meaningful external auditing
14
+ arrives when ``alter-ebpf`` (Patent M) ships on-user-device and can witness
15
+ ``bprm_check_security`` + network syscalls sourced from the daemon UID from
16
+ outside the daemon's control boundary.
17
+
18
+ Until then the ledger is "tell me when I'm lying" telemetry - useful for
19
+ honest operation and revoke-and-shred bookkeeping, NOT a tamper-evident proof.
20
+ Do not describe it as audit-grade, cryptographic, or anti-forgery anywhere.
21
+
22
+ The ledger also supports per-stream shred: revoking a stream (per D-CGT1
23
+ revocability) rewrites the JSONL with stream entries removed. This mirrors
24
+ the revoke-and-shred behaviour described in spec §4.4.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import tempfile
32
+ from dataclasses import asdict, dataclass
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+ from typing import Iterator, Literal
36
+
37
+ from alter_runtime.atlas.schema import ProvenanceClass, SubstrateStream, utcnow
38
+
39
+ LedgerAction = Literal["dry_run", "read", "shredded"]
40
+
41
+ DEFAULT_LEDGER_PATH = Path.home() / ".local" / "state" / "alter" / "atlas" / "reads.jsonl"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class LedgerEntry:
46
+ """One row of the read ledger.
47
+
48
+ Fields are named for JSONL stability - bumps add fields, never rename.
49
+ """
50
+
51
+ action: LedgerAction
52
+ stream: SubstrateStream
53
+ provenance: ProvenanceClass
54
+ extraction_version: int
55
+ at: datetime
56
+ consent_grant_id: str | None
57
+ manifest_hash: str | None
58
+ signature_hash: str | None
59
+ bytes_read: int
60
+ note: str = ""
61
+ schema_version: int = 1
62
+
63
+ def to_json(self) -> str:
64
+ payload = asdict(self)
65
+ payload["stream"] = self.stream.value
66
+ payload["provenance"] = self.provenance.value
67
+ payload["at"] = self.at.astimezone(timezone.utc).isoformat()
68
+ return json.dumps(payload, separators=(",", ":"), sort_keys=True)
69
+
70
+ @classmethod
71
+ def from_json(cls, line: str) -> "LedgerEntry":
72
+ raw = json.loads(line)
73
+ return cls(
74
+ action=raw["action"],
75
+ stream=SubstrateStream(raw["stream"]),
76
+ provenance=ProvenanceClass(raw["provenance"]),
77
+ extraction_version=int(raw["extraction_version"]),
78
+ at=datetime.fromisoformat(raw["at"]),
79
+ consent_grant_id=raw.get("consent_grant_id"),
80
+ manifest_hash=raw.get("manifest_hash"),
81
+ signature_hash=raw.get("signature_hash"),
82
+ bytes_read=int(raw.get("bytes_read", 0)),
83
+ note=raw.get("note", ""),
84
+ schema_version=int(raw.get("schema_version", 1)),
85
+ )
86
+
87
+
88
+ class Ledger:
89
+ """Append-only JSONL read ledger.
90
+
91
+ Soft-signal, local-only. See module docstring for the honesty clause.
92
+ """
93
+
94
+ def __init__(self, path: Path | None = None) -> None:
95
+ self.path = path or DEFAULT_LEDGER_PATH
96
+
97
+ def _ensure_parent(self) -> None:
98
+ self.path.parent.mkdir(parents=True, exist_ok=True)
99
+
100
+ def append(self, entry: LedgerEntry) -> None:
101
+ """Atomically append one entry.
102
+
103
+ POSIX guarantees ``write()`` of <= PIPE_BUF bytes with ``O_APPEND`` is
104
+ atomic against concurrent writers. A JSONL row is well below PIPE_BUF
105
+ (4096 on Linux) for these fixed-size fields, so we rely on that
106
+ without explicit locking.
107
+ """
108
+ self._ensure_parent()
109
+ fd = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
110
+ try:
111
+ os.write(fd, (entry.to_json() + "\n").encode("utf-8"))
112
+ finally:
113
+ os.close(fd)
114
+
115
+ def read_all(self) -> Iterator[LedgerEntry]:
116
+ if not self.path.exists():
117
+ return
118
+ with self.path.open("r", encoding="utf-8") as fh:
119
+ for line in fh:
120
+ line = line.strip()
121
+ if not line:
122
+ continue
123
+ yield LedgerEntry.from_json(line)
124
+
125
+ def read_stream(self, stream: SubstrateStream) -> list[LedgerEntry]:
126
+ return [e for e in self.read_all() if e.stream == stream]
127
+
128
+ def latest_for_stream(self, stream: SubstrateStream) -> LedgerEntry | None:
129
+ latest: LedgerEntry | None = None
130
+ for entry in self.read_stream(stream):
131
+ if latest is None or entry.at > latest.at:
132
+ latest = entry
133
+ return latest
134
+
135
+ def shred_stream(self, stream: SubstrateStream, reason: str = "user_revoke") -> int:
136
+ """Remove all entries for ``stream`` and append a single shred marker.
137
+
138
+ Returns the number of entries removed. The marker entry preserves
139
+ provenance that a shred happened (for dashboard display) without
140
+ retaining the shredded content hashes.
141
+ """
142
+ if not self.path.exists():
143
+ return 0
144
+ kept: list[str] = []
145
+ removed = 0
146
+ with self.path.open("r", encoding="utf-8") as fh:
147
+ for line in fh:
148
+ stripped = line.strip()
149
+ if not stripped:
150
+ continue
151
+ try:
152
+ entry = LedgerEntry.from_json(stripped)
153
+ except (KeyError, ValueError):
154
+ kept.append(line.rstrip("\n"))
155
+ continue
156
+ if entry.stream == stream:
157
+ removed += 1
158
+ continue
159
+ kept.append(stripped)
160
+
161
+ self._ensure_parent()
162
+ tmp_fd, tmp_path = tempfile.mkstemp(
163
+ prefix="reads.", suffix=".jsonl.tmp", dir=self.path.parent
164
+ )
165
+ try:
166
+ with os.fdopen(tmp_fd, "w", encoding="utf-8") as tmp:
167
+ for row in kept:
168
+ tmp.write(row + "\n")
169
+ marker = LedgerEntry(
170
+ action="shredded",
171
+ stream=stream,
172
+ provenance=ProvenanceClass.ACTIVE,
173
+ extraction_version=0,
174
+ at=utcnow(),
175
+ consent_grant_id=None,
176
+ manifest_hash=None,
177
+ signature_hash=None,
178
+ bytes_read=0,
179
+ note=reason,
180
+ )
181
+ tmp.write(marker.to_json() + "\n")
182
+ os.chmod(tmp_path, 0o600)
183
+ os.replace(tmp_path, self.path)
184
+ except Exception:
185
+ with suppress_errors():
186
+ os.unlink(tmp_path)
187
+ raise
188
+ return removed
189
+
190
+
191
+ class suppress_errors:
192
+ def __enter__(self) -> "suppress_errors":
193
+ return self
194
+
195
+ def __exit__(self, exc_type, exc, tb) -> bool:
196
+ return True
@@ -0,0 +1,136 @@
1
+ """Observation renderer - local-only citation list from one or more Signatures.
2
+
3
+ The observation surface is NOT a dashboard, NOT a reading in the tarot sense,
4
+ NOT a therapy session. It is a citation list: specific observations tied to
5
+ specific counts, rendered on-device with zero network round-trip.
6
+
7
+ Tone discipline - OBSERVATION, never INTERPRETATION (spec §6).
8
+
9
+ The cringe kill-zone is any line where Alter names a pattern FOR the user,
10
+ reads meaning into content, or delivers an aphorism. PKM-native users have
11
+ finely-tuned detectors for exactly this move ("you've built a slip-box",
12
+ "you don't want Spanish, you want the version of you that speaks Spanish").
13
+
14
+ The renderer's voice: numbers, timestamps, paths - then stop. User supplies
15
+ meaning. Marcus Aurelius plus an engineer.
16
+
17
+ This module exposes:
18
+
19
+ - ``Observation`` - one line of output carried alongside its slot citations
20
+ (so the user can trace each line back to the exact coefficient).
21
+ - ``render_observations(...)`` - compose header + body from a list of observations.
22
+ - ``lint_observation_line(line)`` - soft-lint for known interpretive phrases.
23
+ Returns a list of reasons the line is likely regressive (adapter-side tests
24
+ can raise on non-empty; CI honesty-clause audit uses the same list).
25
+
26
+ Adapters own the observation templates (they know what their coefficients
27
+ mean); this module only enforces rendering shape + the lint floor.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass
33
+ from datetime import datetime, timezone
34
+ from typing import Sequence
35
+
36
+ from alter_runtime.atlas.schema import Signature
37
+
38
+ OBSERVATION_PREFIX = "- " # em-dash + space, per spec §6 example
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Observation:
43
+ """One rendered observation line.
44
+
45
+ ``citations`` lists coefficient names from the signature this line reads
46
+ - used for dashboard trace-back ("this line came from which numbers?")
47
+ and for the post-render smoke-test that every observation can be cited.
48
+ """
49
+
50
+ line: str
51
+ citations: tuple[str, ...] = ()
52
+
53
+
54
+ FORBIDDEN_PHRASES: tuple[str, ...] = (
55
+ "you've built",
56
+ "you have built",
57
+ "you're a",
58
+ "you are a",
59
+ "what you really want",
60
+ "what you truly want",
61
+ "the version of you",
62
+ "deep down",
63
+ "slip-box",
64
+ "zettelkasten",
65
+ "second brain",
66
+ "night owl",
67
+ "early bird",
68
+ "ghost ambitions",
69
+ "shadow self",
70
+ "your archetype",
71
+ "your type",
72
+ )
73
+
74
+
75
+ def lint_observation_line(line: str) -> list[str]:
76
+ """Return reasons this line likely violates §6 observation-only discipline.
77
+
78
+ This is a soft lint - it does not catch every regression (novel
79
+ interpretive phrasings will slip past) but it catches the spec-named
80
+ kill-zone phrases and common second-person verdict openers. Adapter
81
+ tests should assert this returns ``[]`` for every shipped template.
82
+ """
83
+ reasons: list[str] = []
84
+ lower = line.lower()
85
+ for phrase in FORBIDDEN_PHRASES:
86
+ if phrase in lower:
87
+ reasons.append(f"forbidden phrase: {phrase!r}")
88
+ stripped = lower.lstrip("- -")
89
+ for opener in ("you've ", "you are ", "you're "):
90
+ if stripped.startswith(opener):
91
+ reasons.append(
92
+ f"second-person verdict opener: {opener!r} - prefer counts/timestamps/paths"
93
+ )
94
+ break
95
+ return reasons
96
+
97
+
98
+ def render_observations(
99
+ handle: str,
100
+ signatures: Sequence[Signature],
101
+ observations: Sequence[Observation],
102
+ rendered_at: datetime | None = None,
103
+ ) -> str:
104
+ """Compose the observation text.
105
+
106
+ Output shape (spec §6 example):
107
+
108
+ Atlas / ~blake / 2026-04-17
109
+
110
+ - 47 of your 83 commits this year landed between 23:00 and 02:00
111
+ - You commit on Tuesday (19) and Sunday (14) more than any other day
112
+ - Your commit verbs: feat 31 · fix 22 · refactor 14 · chore 9 · docs 7
113
+
114
+ The header uses the ``~handle`` form per D-ID1–ID8 namespace convention.
115
+ Observations render in the order supplied - adapters are expected to
116
+ pre-order by most to least surprising count.
117
+
118
+ This function does not enforce ``lint_observation_line`` - that is the
119
+ adapter's responsibility in its unit test. Render-time failures in prod
120
+ would block the observation surface for a user whose machine produced an
121
+ edge-case template; soft-lint at test time, not runtime.
122
+ """
123
+ handle_display = handle if handle.startswith("~") else f"~{handle}"
124
+ when = rendered_at or datetime.now(timezone.utc)
125
+ date_line = when.strftime("%Y-%m-%d")
126
+
127
+ streams = "·".join(sorted({s.stream.value for s in signatures}))
128
+ # Streams line only renders when more than one stream is cited - keeps
129
+ # single-adapter output (T0 git-only) matching the spec example verbatim.
130
+ header_lines = [f"Atlas / {handle_display} / {date_line}"]
131
+ if streams and len(signatures) > 1:
132
+ header_lines.append(f"streams: {streams}")
133
+
134
+ body_lines = [f"{OBSERVATION_PREFIX}{obs.line}" for obs in observations]
135
+
136
+ return "\n".join(header_lines + [""] + body_lines) + "\n"
@@ -0,0 +1,106 @@
1
+ """Atlas signature schema - the derivative vector shape.
2
+
3
+ A Signature is the output of one adapter reading one substrate once. It carries:
4
+ * a 128-dim coefficient vector (padded with NaN for adapter-specific shorter
5
+ outputs - each adapter documents which slots it populates)
6
+ * provenance metadata (which stream, which extraction version, when)
7
+ * a content-free hash for server-crossing under depth-fork consent
8
+
9
+ Per spec §2 honesty clause: the hash is NOT a pseudonymisation guarantee. It is
10
+ re-identifiable via membership inference at scale. Protection is legal (per-stream
11
+ Art-6(1)(a) consent + audit) + operational (no-cross-join policy) - not
12
+ architectural.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import enum
18
+ import hashlib
19
+ from dataclasses import dataclass
20
+ from datetime import datetime, timezone
21
+
22
+ SIGNATURE_DIM: int = 128
23
+ SIGNATURE_SCHEMA_VERSION: int = 1
24
+
25
+
26
+ class SubstrateStream(str, enum.Enum):
27
+ """Enumeration of shipped + parked substrate streams.
28
+
29
+ Each stream is gated independently for consent per D-IaI-1.5.
30
+ """
31
+
32
+ GIT = "git"
33
+ SHELL = "shell"
34
+ VAULT = "vault"
35
+ WINDOWS = "windows"
36
+ DOWNLOADS = "downloads"
37
+ CALENDAR = "calendar"
38
+ NOTES = "notes"
39
+ RECENTS = "recents"
40
+ AESTHETIC = "aesthetic"
41
+ MUSIC = "music" # Phase 2 - Drew-gated E-tier firewall DR required
42
+
43
+
44
+ class ProvenanceClass(str, enum.Enum):
45
+ """D-IaI-1.2 provenance classes - gate consent by (stream × provenance)."""
46
+
47
+ ACTIVE = "active" # user-initiated read (`alter atlas read`)
48
+ PASSIVE_LOCAL = "passive_local" # daemon subscription, LOCAL-ONLY
49
+ PASSIVE_AGGREGATE = "passive_aggregate" # k>=1000 population observation
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class SignatureCoefficient:
54
+ """One named slot in the 128-dim vector.
55
+
56
+ Adapters declare which coefficients they populate; unpopulated slots stay
57
+ NaN. The name is canonical across versions - schema bumps add coefficients,
58
+ never rename.
59
+ """
60
+
61
+ slot: int
62
+ name: str
63
+ value: float
64
+ unit: str | None = None
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Signature:
69
+ """One adapter's read of one substrate at one moment."""
70
+
71
+ stream: SubstrateStream
72
+ provenance: ProvenanceClass
73
+ extracted_at: datetime
74
+ extraction_version: int
75
+ coefficients: tuple[SignatureCoefficient, ...]
76
+ schema_version: int = SIGNATURE_SCHEMA_VERSION
77
+
78
+ def to_vector(self) -> list[float]:
79
+ """Dense 128-dim vector with NaN for unpopulated slots."""
80
+ vec = [float("nan")] * SIGNATURE_DIM
81
+ for coeff in self.coefficients:
82
+ if 0 <= coeff.slot < SIGNATURE_DIM:
83
+ vec[coeff.slot] = coeff.value
84
+ return vec
85
+
86
+ def content_free_hash(self) -> str:
87
+ """BLAKE3-ish stable hash over (stream, version, sorted coefficients).
88
+
89
+ Uses sha256 for stdlib availability; BLAKE3 is a post-launch upgrade.
90
+ This hash is re-identifiable at scale (spec §2 honesty clause) - do
91
+ not treat as pseudonymisation.
92
+ """
93
+ h = hashlib.sha256()
94
+ h.update(self.stream.value.encode())
95
+ h.update(self.schema_version.to_bytes(4, "big"))
96
+ h.update(self.extraction_version.to_bytes(4, "big"))
97
+ for coeff in sorted(self.coefficients, key=lambda c: c.slot):
98
+ h.update(coeff.slot.to_bytes(2, "big"))
99
+ h.update(coeff.name.encode())
100
+ # Fixed-width float repr so hash is stable across Python versions.
101
+ h.update(f"{coeff.value:.9e}".encode())
102
+ return h.hexdigest()
103
+
104
+
105
+ def utcnow() -> datetime:
106
+ return datetime.now(timezone.utc)