passiveworkers 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.
- council/__init__.py +1 -0
- council/artifacts.py +161 -0
- council/batch.py +84 -0
- council/cli.py +54 -0
- council/coordinator.py +133 -0
- council/crypto.py +133 -0
- council/fidelity.py +197 -0
- council/judge.py +393 -0
- council/ledger.py +230 -0
- council/library.py +431 -0
- council/local.py +228 -0
- council/mcp_server.py +87 -0
- council/net/__init__.py +1 -0
- council/net/agent.py +231 -0
- council/net/app.py +390 -0
- council/net/baseline.py +86 -0
- council/net/config.py +79 -0
- council/net/coordinator_app.py +370 -0
- council/net/dashboard.py +111 -0
- council/net/store.py +964 -0
- council/net/submit.py +102 -0
- council/operator.py +412 -0
- council/research.py +520 -0
- council/researcher.py +300 -0
- council/retrieval.py +80 -0
- council/run_demo.py +175 -0
- council/sanitize.py +78 -0
- council/serve.py +183 -0
- council/trust.py +168 -0
- council/worker.py +123 -0
- passiveworkers-0.1.0.dist-info/METADATA +269 -0
- passiveworkers-0.1.0.dist-info/RECORD +36 -0
- passiveworkers-0.1.0.dist-info/WHEEL +5 -0
- passiveworkers-0.1.0.dist-info/entry_points.txt +2 -0
- passiveworkers-0.1.0.dist-info/licenses/LICENSE +21 -0
- passiveworkers-0.1.0.dist-info/top_level.txt +1 -0
council/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Passive Workers — the Council: mutual-aid collective-intelligence MVP."""
|
council/artifacts.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
council/artifacts.py — content-addressed, chunked, integrity-verified file delivery (D22)
|
|
4
|
+
=========================================================================================
|
|
5
|
+
Real marketplace work produces FILES, not just text. This is the lean, dependency-free
|
|
6
|
+
codec for moving them between machines safely (FEDERATION_V2 step 3):
|
|
7
|
+
|
|
8
|
+
• split a file into fixed-size chunks, hash each (sha256) → the chunk is its own address
|
|
9
|
+
• a manifest records {name, size, chunk_size, root, chunks:[hashes]}; `root` = sha256 of
|
|
10
|
+
the ordered chunk hashes (a flat Merkle root) — the tamper-evident fingerprint
|
|
11
|
+
• the coordinator stores chunks as OPAQUE content-addressed blobs (dedup for free)
|
|
12
|
+
• the receiver fetches each chunk by hash, verifies it against the manifest, and only
|
|
13
|
+
reassembles if every chunk and the root check out — a corrupted or swapped chunk is
|
|
14
|
+
detected, not written
|
|
15
|
+
|
|
16
|
+
Encryption + producer signatures layer on top of this later (the [crypto] extra); the
|
|
17
|
+
content-addressing here already gives integrity. Stdlib only.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import pathlib
|
|
24
|
+
|
|
25
|
+
CHUNK_SIZE = 256 * 1024 # 256 KiB chunks
|
|
26
|
+
MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MiB per deliverable file (v1 cap)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_ARTIFACT_TAG = "__pw_artifact__"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _h(data: bytes) -> str:
|
|
33
|
+
return hashlib.sha256(data).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def wrap_artifact(manifest: dict) -> str:
|
|
37
|
+
"""Serialize a manifest as a file-deliverable, tagged so it can't be confused with a
|
|
38
|
+
user's text deliverable that merely happens to be JSON."""
|
|
39
|
+
import json
|
|
40
|
+
return json.dumps({_ARTIFACT_TAG: 1, "manifest": manifest})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_artifact(deliverable: str):
|
|
44
|
+
"""Return the manifest if `deliverable` is a tagged file artifact, else None (it's text)."""
|
|
45
|
+
import json
|
|
46
|
+
try:
|
|
47
|
+
d = json.loads(deliverable)
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
if isinstance(d, dict) and d.get(_ARTIFACT_TAG) == 1 and isinstance(d.get("manifest"), dict):
|
|
51
|
+
return d["manifest"]
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def manifest_root(chunk_hashes: list[str]) -> str:
|
|
56
|
+
"""Flat Merkle root: sha256 over the ordered chunk hashes. Order matters (reassembly)."""
|
|
57
|
+
return hashlib.sha256("".join(chunk_hashes).encode()).hexdigest()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def chunk_file(path: str) -> tuple[dict, dict[str, bytes]]:
|
|
61
|
+
"""(manifest, {hash: bytes}). Raises if the file is missing or over the size cap."""
|
|
62
|
+
p = pathlib.Path(path).expanduser().resolve()
|
|
63
|
+
if not p.is_file():
|
|
64
|
+
raise ValueError(f"not a file: {path}")
|
|
65
|
+
size = p.stat().st_size
|
|
66
|
+
if size > MAX_FILE_BYTES:
|
|
67
|
+
raise ValueError(f"file too large ({size // 1_000_000} MB > {MAX_FILE_BYTES // 1_000_000} MB)")
|
|
68
|
+
blobs: dict[str, bytes] = {}
|
|
69
|
+
order: list[str] = []
|
|
70
|
+
with p.open("rb") as f:
|
|
71
|
+
while True:
|
|
72
|
+
buf = f.read(CHUNK_SIZE)
|
|
73
|
+
if not buf:
|
|
74
|
+
break
|
|
75
|
+
h = _h(buf)
|
|
76
|
+
blobs[h] = buf
|
|
77
|
+
order.append(h)
|
|
78
|
+
manifest = {"name": p.name, "size": size, "chunk_size": CHUNK_SIZE,
|
|
79
|
+
"chunks": order, "root": manifest_root(order)}
|
|
80
|
+
return manifest, blobs
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_HEX64 = __import__("re").compile(r"^[0-9a-f]{64}$")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def chunk_file_encrypted(path: str, seal_fn) -> tuple[dict, dict[str, bytes]]:
|
|
87
|
+
"""Like chunk_file, but each plaintext chunk is encrypted via seal_fn(bytes)->bytes before
|
|
88
|
+
hashing/storing. The blob address is the hash of the CIPHERTEXT (so content-addressing and
|
|
89
|
+
integrity still apply); the manifest is flagged `encrypted` and its size is the PLAINTEXT
|
|
90
|
+
size. The coordinator only ever stores ciphertext it cannot read (D23)."""
|
|
91
|
+
p = pathlib.Path(path).expanduser().resolve()
|
|
92
|
+
if not p.is_file():
|
|
93
|
+
raise ValueError(f"not a file: {path}")
|
|
94
|
+
size = p.stat().st_size
|
|
95
|
+
if size > MAX_FILE_BYTES:
|
|
96
|
+
raise ValueError(f"file too large ({size // 1_000_000} MB > {MAX_FILE_BYTES // 1_000_000} MB)")
|
|
97
|
+
blobs: dict[str, bytes] = {}
|
|
98
|
+
order: list[str] = []
|
|
99
|
+
with p.open("rb") as f:
|
|
100
|
+
while True:
|
|
101
|
+
buf = f.read(CHUNK_SIZE)
|
|
102
|
+
if not buf:
|
|
103
|
+
break
|
|
104
|
+
ct = seal_fn(buf)
|
|
105
|
+
h = _h(ct)
|
|
106
|
+
blobs[h] = ct
|
|
107
|
+
order.append(h)
|
|
108
|
+
manifest = {"name": p.name, "size": size, "chunk_size": CHUNK_SIZE,
|
|
109
|
+
"chunks": order, "root": manifest_root(order), "encrypted": True}
|
|
110
|
+
return manifest, blobs
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def verify_manifest(manifest: dict) -> bool:
|
|
114
|
+
"""The manifest's declared root must match its ordered chunk list, chunk hashes must be
|
|
115
|
+
well-formed, and size must be a non-negative int (catches a doctored manifest before we
|
|
116
|
+
fetch anything). An empty file (size 0, no chunks) is valid."""
|
|
117
|
+
chunks = manifest.get("chunks")
|
|
118
|
+
size = manifest.get("size")
|
|
119
|
+
if not isinstance(chunks, list) or not isinstance(size, int) or size < 0:
|
|
120
|
+
return False
|
|
121
|
+
if not all(isinstance(h, str) and _HEX64.match(h) for h in chunks):
|
|
122
|
+
return False
|
|
123
|
+
return manifest.get("root") == manifest_root(chunks)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def reassemble(manifest: dict, fetch_chunk, out_dir: str, decrypt=None) -> pathlib.Path:
|
|
127
|
+
"""Fetch each chunk via fetch_chunk(hash)->bytes, VERIFY each against its hash, and write
|
|
128
|
+
the file into out_dir only if everything checks out. fetch_chunk failures or any hash
|
|
129
|
+
mismatch raise — a corrupted/swapped chunk never reaches disk. Path-safe: the manifest
|
|
130
|
+
name is reduced to a basename inside out_dir. If the manifest is `encrypted`, `decrypt`
|
|
131
|
+
(bytes->bytes) is applied AFTER the ciphertext hash is verified."""
|
|
132
|
+
if manifest.get("encrypted") and decrypt is None:
|
|
133
|
+
raise ValueError("manifest is encrypted but no decrypt key provided")
|
|
134
|
+
if not verify_manifest(manifest):
|
|
135
|
+
raise ValueError("manifest root does not match its chunk list")
|
|
136
|
+
out = pathlib.Path(out_dir).expanduser().resolve()
|
|
137
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
name = pathlib.Path(str(manifest.get("name", ""))).name or "deliverable.bin" # strip traversal
|
|
139
|
+
dest = (out / name).resolve()
|
|
140
|
+
# segment-aware containment (not a bare startswith, which sibling-prefixes could fool)
|
|
141
|
+
if dest != out and out not in dest.parents:
|
|
142
|
+
raise ValueError("unsafe output path")
|
|
143
|
+
total = 0
|
|
144
|
+
encrypted = bool(manifest.get("encrypted"))
|
|
145
|
+
with dest.open("wb") as f:
|
|
146
|
+
for h in manifest["chunks"]:
|
|
147
|
+
data = fetch_chunk(h)
|
|
148
|
+
if data is None or _h(data) != h: # verify the (cipher)text against its address
|
|
149
|
+
raise ValueError(f"chunk {h[:12]}… missing or corrupted — aborting")
|
|
150
|
+
if encrypted:
|
|
151
|
+
try:
|
|
152
|
+
data = decrypt(data) # decrypt AFTER integrity check
|
|
153
|
+
except Exception:
|
|
154
|
+
dest.unlink(missing_ok=True)
|
|
155
|
+
raise ValueError("decryption failed (wrong key or tampered chunk)")
|
|
156
|
+
f.write(data)
|
|
157
|
+
total += len(data)
|
|
158
|
+
if manifest.get("size") is not None and total != manifest["size"]:
|
|
159
|
+
dest.unlink(missing_ok=True)
|
|
160
|
+
raise ValueError("reassembled size mismatch")
|
|
161
|
+
return dest
|
council/batch.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
council/batch.py — the per-node batch worker for `shard_map` jobs (D13/D15)
|
|
4
|
+
============================================================================
|
|
5
|
+
One big job, split across computers: this node receives its SHARD of the items and
|
|
6
|
+
applies the instruction to each item with its local model. Wall-clock scales with
|
|
7
|
+
items ÷ computers — the honest "divide to save time" the marketplace sells.
|
|
8
|
+
|
|
9
|
+
With `fetch=True`, items are PUBLIC URLs: the node fetches each one itself (SSRF-guarded,
|
|
10
|
+
one polite request, size-capped) and returns the model's EXTRACTION — never the raw page.
|
|
11
|
+
The D15 bright line: a node only fetches what it could lawfully fetch alone, and only
|
|
12
|
+
value-added output leaves the node. No relaying, no rate-limit evasion.
|
|
13
|
+
|
|
14
|
+
Per-item failures never kill the shard — the item's output records the error and the
|
|
15
|
+
batch continues (the judge's spot-check and score reflect quality).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
from council.research import fetch_extract
|
|
27
|
+
from council.sanitize import clean, spotlight
|
|
28
|
+
|
|
29
|
+
OLLAMA_BASE = "http://localhost:11434"
|
|
30
|
+
_GEN_TIMEOUT = float(os.environ.get("PW_BATCH_GEN_TIMEOUT",
|
|
31
|
+
os.environ.get("PW_OLLAMA_TIMEOUT", "480")))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class BatchWorker:
|
|
36
|
+
worker_id: str
|
|
37
|
+
model: str
|
|
38
|
+
country: str = "local"
|
|
39
|
+
temperature: float = 0.2
|
|
40
|
+
ollama_base: str = OLLAMA_BASE
|
|
41
|
+
|
|
42
|
+
def _generate(self, prompt: str, num_predict: int = 220) -> tuple[str, int]:
|
|
43
|
+
r = requests.post(
|
|
44
|
+
f"{self.ollama_base}/api/generate",
|
|
45
|
+
json={"model": self.model, "prompt": prompt, "stream": False,
|
|
46
|
+
"options": {"temperature": self.temperature, "num_predict": num_predict},
|
|
47
|
+
# keep the model warm across this shard's items (R17) — batch loops _generate per item
|
|
48
|
+
"keep_alive": os.environ.get("PW_OLLAMA_KEEP_ALIVE", "30m")},
|
|
49
|
+
timeout=_GEN_TIMEOUT,
|
|
50
|
+
)
|
|
51
|
+
r.raise_for_status()
|
|
52
|
+
d = r.json()
|
|
53
|
+
return (d.get("response") or "").strip(), (d.get("eval_count") or 0)
|
|
54
|
+
|
|
55
|
+
def process(self, instruction: str, shard: list[dict], fetch: bool = False) -> dict:
|
|
56
|
+
t0 = time.monotonic()
|
|
57
|
+
# the instruction is the asker's TASK (kept as a directive) but still scrubbed of invisible/
|
|
58
|
+
# bidi/HTML-comment injection vectors; every per-item value is untrusted DATA → spotlighted.
|
|
59
|
+
instruction = clean(instruction)
|
|
60
|
+
results, tokens, ok = [], 0, 0
|
|
61
|
+
for entry in shard:
|
|
62
|
+
idx, item = entry.get("i", 0), str(entry.get("item", ""))
|
|
63
|
+
try:
|
|
64
|
+
if fetch:
|
|
65
|
+
content = fetch_extract(item)
|
|
66
|
+
prompt = (f"{instruction}\n\nSOURCE URL: {item}\n"
|
|
67
|
+
f"PAGE TEXT (extracted):\n{spotlight(content)}\n\nOUTPUT:")
|
|
68
|
+
else:
|
|
69
|
+
prompt = f"{instruction}\n\nITEM:\n{spotlight(item)}\n\nOUTPUT (concise):"
|
|
70
|
+
out, tk = self._generate(prompt)
|
|
71
|
+
tokens += tk
|
|
72
|
+
ok += 1
|
|
73
|
+
results.append({"i": idx, "item": item, "output": out})
|
|
74
|
+
except Exception as e:
|
|
75
|
+
results.append({"i": idx, "item": item,
|
|
76
|
+
"output": f"(error: {type(e).__name__}: {str(e)[:120]})"})
|
|
77
|
+
elapsed = round(time.monotonic() - t0, 2)
|
|
78
|
+
return {
|
|
79
|
+
"text": (f"Processed {ok}/{len(shard)} items in {elapsed}s on this "
|
|
80
|
+
f"{self.country} node ({self.model})."),
|
|
81
|
+
"results": results,
|
|
82
|
+
"tokens": tokens,
|
|
83
|
+
"elapsed_s": elapsed,
|
|
84
|
+
}
|
council/cli.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
council/cli.py — the `pw` command
|
|
4
|
+
==================================
|
|
5
|
+
pw research "your brief" [--quick|--deep] [--editor api] [--analysts N] [--local|--web]
|
|
6
|
+
pw serve # local research desk at http://127.0.0.1:8770
|
|
7
|
+
pw library add <path|dir> # index your own documents (private, local RAG)
|
|
8
|
+
pw library list|remove|clear
|
|
9
|
+
pw mcp # run as an MCP server (Claude Desktop, Codex, …)
|
|
10
|
+
pw tasks # list open assisted offers you can do (federation)
|
|
11
|
+
pw accept <id> | deliver <id> <text|@file>
|
|
12
|
+
pw fetch <job> <dir> # download + verify a delivered file (asker)
|
|
13
|
+
pw rate <job> <0-10> # rate an assisted deliverable → operator reputation (asker)
|
|
14
|
+
pw fingerprint # print your signing key + fingerprint to share (operator)
|
|
15
|
+
pw trust add <op> <key> | list | remove <op> # pin operator keys out of band (asker)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
args = sys.argv[1:]
|
|
25
|
+
if not args or args[0] in ("-h", "--help"):
|
|
26
|
+
print(__doc__.strip())
|
|
27
|
+
return 0
|
|
28
|
+
cmd, rest = args[0], args[1:]
|
|
29
|
+
if cmd == "research":
|
|
30
|
+
sys.argv = ["pw research"] + rest
|
|
31
|
+
from council.local import main as research_main
|
|
32
|
+
return research_main()
|
|
33
|
+
if cmd == "serve":
|
|
34
|
+
from council.serve import main as serve_main
|
|
35
|
+
serve_main()
|
|
36
|
+
return 0
|
|
37
|
+
if cmd == "library":
|
|
38
|
+
sys.argv = ["pw library"] + rest
|
|
39
|
+
from council.library import main as library_main
|
|
40
|
+
return library_main()
|
|
41
|
+
if cmd == "mcp":
|
|
42
|
+
from council.mcp_server import main as mcp_main
|
|
43
|
+
mcp_main()
|
|
44
|
+
return 0
|
|
45
|
+
if cmd in ("tasks", "accept", "deliver", "fetch", "keygen", "rate", "fingerprint", "trust"):
|
|
46
|
+
sys.argv = ["pw", cmd] + rest # operator.main dispatches on argv[1] (the verb)
|
|
47
|
+
from council.operator import main as operator_main
|
|
48
|
+
return operator_main()
|
|
49
|
+
print(f"unknown command: {cmd}\n\n{__doc__.strip()}")
|
|
50
|
+
return 2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
sys.exit(main())
|
council/coordinator.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
council/coordinator.py — the orchestrator
|
|
4
|
+
=========================================
|
|
5
|
+
Ties the loop together:
|
|
6
|
+
|
|
7
|
+
intake → concurrent fan-out to a diverse fleet → judge scores + merges →
|
|
8
|
+
score-weighted ledger settlement → result bundle
|
|
9
|
+
|
|
10
|
+
The coordinator is the open-source, self-hostable hub. It holds the non-transferable
|
|
11
|
+
ledger and the worker registry; it never holds a tradeable token and never relays raw
|
|
12
|
+
traffic — it routes TASKS and settles CREDIT for delivered answers.
|
|
13
|
+
|
|
14
|
+
A worker is owned by a participant (its `owner`); credit for that worker's answer flows
|
|
15
|
+
to the owner's account. That is how "your machine helping on my question earns YOU
|
|
16
|
+
credit" works — and how a user who both asks and helps keeps their give/take balanced.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
from council.judge import Judge, ScoredCandidate
|
|
26
|
+
from council.ledger import InsufficientCredit, Ledger, Receipt
|
|
27
|
+
from council.worker import Answer, PerspectiveWorker
|
|
28
|
+
|
|
29
|
+
WORKER_POOL = 30.0 # credits split among helpers per job (by score)
|
|
30
|
+
JUDGE_FEE = 5.0 # credits to the judge per job
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CouncilResult:
|
|
35
|
+
job_id: str
|
|
36
|
+
question: str
|
|
37
|
+
asker_id: str
|
|
38
|
+
answers: list[Answer]
|
|
39
|
+
scored: list[ScoredCandidate]
|
|
40
|
+
merged_answer: str
|
|
41
|
+
receipt: Receipt
|
|
42
|
+
elapsed_s: float
|
|
43
|
+
owner_of: dict = field(default_factory=dict) # worker_id -> owner_id
|
|
44
|
+
|
|
45
|
+
def best_single(self) -> Answer:
|
|
46
|
+
best_wid = max(self.scored, key=lambda s: s.score).worker_id
|
|
47
|
+
return next(a for a in self.answers if a.worker_id == best_wid)
|
|
48
|
+
|
|
49
|
+
def score_for(self, worker_id: str) -> float:
|
|
50
|
+
return next((s.score for s in self.scored if s.worker_id == worker_id), 0.0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Council:
|
|
55
|
+
ledger: Ledger
|
|
56
|
+
judge: Judge
|
|
57
|
+
judge_owner_id: str = "judge_node"
|
|
58
|
+
worker_pool: float = WORKER_POOL
|
|
59
|
+
judge_fee: float = JUDGE_FEE
|
|
60
|
+
_job_seq: int = 0
|
|
61
|
+
|
|
62
|
+
def run(
|
|
63
|
+
self,
|
|
64
|
+
asker_id: str,
|
|
65
|
+
question: str,
|
|
66
|
+
fleet: list[PerspectiveWorker],
|
|
67
|
+
owner_of: dict | None = None,
|
|
68
|
+
) -> CouncilResult:
|
|
69
|
+
owner_of = dict(owner_of or {})
|
|
70
|
+
for w in fleet:
|
|
71
|
+
owner_of.setdefault(w.worker_id, w.worker_id)
|
|
72
|
+
|
|
73
|
+
# Make sure every participant has an account.
|
|
74
|
+
self.ledger.open_account(asker_id)
|
|
75
|
+
self.ledger.open_account(self.judge_owner_id)
|
|
76
|
+
for w in fleet:
|
|
77
|
+
self.ledger.open_account(owner_of[w.worker_id])
|
|
78
|
+
|
|
79
|
+
# Affordability gate — the give/take rule in action.
|
|
80
|
+
cost = self.ledger.quote(self.worker_pool, self.judge_fee)
|
|
81
|
+
if not self.ledger.can_afford(asker_id, cost):
|
|
82
|
+
raise InsufficientCredit(
|
|
83
|
+
f"{asker_id} cannot afford {cost:.1f} credits "
|
|
84
|
+
f"(balance {self.ledger.balance(asker_id):.1f}). Help on a job first."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
self._job_seq += 1
|
|
88
|
+
job_id = f"job{self._job_seq:03d}"
|
|
89
|
+
t0 = time.monotonic()
|
|
90
|
+
|
|
91
|
+
# Concurrent fan-out — every perspective answers in parallel.
|
|
92
|
+
answers: list[Answer] = []
|
|
93
|
+
with ThreadPoolExecutor(max_workers=len(fleet)) as pool:
|
|
94
|
+
futures = {pool.submit(w.answer, question): w for w in fleet}
|
|
95
|
+
for fut in as_completed(futures):
|
|
96
|
+
try:
|
|
97
|
+
answers.append(fut.result())
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
w = futures[fut]
|
|
100
|
+
print(f" [coordinator] worker {w.worker_id} failed: {exc}")
|
|
101
|
+
# Stable order by worker_id for reproducible display.
|
|
102
|
+
answers.sort(key=lambda a: a.worker_id)
|
|
103
|
+
|
|
104
|
+
# Judge: score (blind) then merge.
|
|
105
|
+
scored = self.judge.score(question, answers)
|
|
106
|
+
merged = self.judge.merge(question, answers)
|
|
107
|
+
|
|
108
|
+
# Aggregate scores by OWNER (a user running >1 model sums their scores).
|
|
109
|
+
score_by_owner: dict[str, float] = {}
|
|
110
|
+
for sc in scored:
|
|
111
|
+
owner = owner_of[sc.worker_id]
|
|
112
|
+
score_by_owner[owner] = score_by_owner.get(owner, 0.0) + sc.score
|
|
113
|
+
|
|
114
|
+
receipt = self.ledger.settle_job(
|
|
115
|
+
job_id=job_id,
|
|
116
|
+
asker_id=asker_id,
|
|
117
|
+
score_by_worker=score_by_owner,
|
|
118
|
+
worker_pool=self.worker_pool,
|
|
119
|
+
judge_id=self.judge_owner_id,
|
|
120
|
+
judge_fee=self.judge_fee,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return CouncilResult(
|
|
124
|
+
job_id=job_id,
|
|
125
|
+
question=question,
|
|
126
|
+
asker_id=asker_id,
|
|
127
|
+
answers=answers,
|
|
128
|
+
scored=scored,
|
|
129
|
+
merged_answer=merged,
|
|
130
|
+
receipt=receipt,
|
|
131
|
+
elapsed_s=time.monotonic() - t0,
|
|
132
|
+
owner_of=owner_of,
|
|
133
|
+
)
|
council/crypto.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
council/crypto.py — optional cryptographic delivery (D23, FEDERATION_V2 step 2)
|
|
4
|
+
================================================================================
|
|
5
|
+
Two real guarantees layered on top of content-addressed delivery (D22), both OPTIONAL
|
|
6
|
+
(the [crypto] extra; everything degrades gracefully to D22 integrity when PyNaCl is absent):
|
|
7
|
+
|
|
8
|
+
• SIGNING (Ed25519): an operator signs its deliverable with a private key the coordinator
|
|
9
|
+
never sees; the asker verifies against the operator's published public key. Detects any
|
|
10
|
+
post-delivery tampering of the content and binds the deliverable to the operator's key.
|
|
11
|
+
(Honest limit: a hostile coordinator that swaps content+key+sig together needs an
|
|
12
|
+
out-of-band key check / PKI to defeat — documented, not papered over.)
|
|
13
|
+
• ENCRYPTION (SealedBox / X25519): the asker publishes a public key with the job; the
|
|
14
|
+
operator seals the payload to it; only the asker's private key can open it. The
|
|
15
|
+
coordinator relays ciphertext it genuinely cannot read — a real confidentiality
|
|
16
|
+
guarantee even against a hostile coordinator.
|
|
17
|
+
|
|
18
|
+
Keys are base64 strings so they travel as plain JSON. Helpers persist a keypair per identity
|
|
19
|
+
in ~/.passiveworkers so sign/verify and seal/unseal survive across CLI invocations.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import pathlib
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from nacl.public import PrivateKey, PublicKey, SealedBox
|
|
31
|
+
from nacl.signing import SigningKey, VerifyKey
|
|
32
|
+
from nacl.exceptions import BadSignatureError, CryptoError
|
|
33
|
+
HAVE_CRYPTO = True
|
|
34
|
+
except Exception: # pragma: no cover - optional dependency
|
|
35
|
+
HAVE_CRYPTO = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def available() -> bool:
|
|
39
|
+
return HAVE_CRYPTO
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require() -> None:
|
|
43
|
+
if not HAVE_CRYPTO:
|
|
44
|
+
raise RuntimeError("the crypto extra is not installed: pip install 'passiveworkers[crypto]'")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _b64e(b: bytes) -> str:
|
|
48
|
+
return base64.b64encode(b).decode()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _b64d(s: str) -> bytes:
|
|
52
|
+
return base64.b64decode(s.encode(), validate=True) # reject whitespace/non-alphabet
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------ keypairs
|
|
56
|
+
def new_signing_keypair() -> tuple[str, str]:
|
|
57
|
+
"""(private_b64, public_b64) for Ed25519 signing."""
|
|
58
|
+
_require()
|
|
59
|
+
sk = SigningKey.generate()
|
|
60
|
+
return _b64e(bytes(sk)), _b64e(bytes(sk.verify_key))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def new_box_keypair() -> tuple[str, str]:
|
|
64
|
+
"""(private_b64, public_b64) for X25519 sealed-box encryption."""
|
|
65
|
+
_require()
|
|
66
|
+
sk = PrivateKey.generate()
|
|
67
|
+
return _b64e(bytes(sk)), _b64e(bytes(sk.public_key))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_or_create(path: pathlib.Path, kind: str) -> dict:
|
|
71
|
+
"""Persist+reuse a keypair of `kind` ('sign' or 'box') at `path`. Returns {priv, pub}."""
|
|
72
|
+
if path.exists():
|
|
73
|
+
try:
|
|
74
|
+
d = json.loads(path.read_text()).get(kind)
|
|
75
|
+
if d and d.get("priv") and d.get("pub"):
|
|
76
|
+
return d
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
priv, pub = new_signing_keypair() if kind == "sign" else new_box_keypair()
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
allk = {}
|
|
82
|
+
if path.exists():
|
|
83
|
+
try:
|
|
84
|
+
allk = json.loads(path.read_text())
|
|
85
|
+
except Exception:
|
|
86
|
+
allk = {}
|
|
87
|
+
allk[kind] = {"priv": priv, "pub": pub}
|
|
88
|
+
# write with owner-only perms FROM CREATION (no world-readable window before chmod)
|
|
89
|
+
blob = json.dumps(allk).encode()
|
|
90
|
+
try:
|
|
91
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
92
|
+
with os.fdopen(fd, "wb") as f:
|
|
93
|
+
f.write(blob)
|
|
94
|
+
os.chmod(path, 0o600)
|
|
95
|
+
except Exception:
|
|
96
|
+
path.write_text(json.dumps(allk)) # fallback (e.g. platforms without mode support)
|
|
97
|
+
try:
|
|
98
|
+
os.chmod(path, 0o600) # a private key must never be world-readable
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
return allk[kind]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------ signing
|
|
105
|
+
def sign(priv_b64: str, data: bytes) -> str:
|
|
106
|
+
"""Detached Ed25519 signature (b64) over `data`."""
|
|
107
|
+
_require()
|
|
108
|
+
return _b64e(SigningKey(_b64d(priv_b64)).sign(data).signature)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def verify(pub_b64: str, data: bytes, sig_b64: str) -> bool:
|
|
112
|
+
"""True iff `sig` is a valid signature of `data` under `pub`. Never raises (returns False
|
|
113
|
+
on any error, including when the crypto extra is absent)."""
|
|
114
|
+
if not HAVE_CRYPTO:
|
|
115
|
+
return False
|
|
116
|
+
try:
|
|
117
|
+
VerifyKey(_b64d(pub_b64)).verify(data, _b64d(sig_b64))
|
|
118
|
+
return True
|
|
119
|
+
except Exception:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------ encryption
|
|
124
|
+
def seal(recipient_pub_b64: str, data: bytes) -> bytes:
|
|
125
|
+
"""Anonymous-sender encryption to a recipient public key (X25519 SealedBox)."""
|
|
126
|
+
_require()
|
|
127
|
+
return SealedBox(PublicKey(_b64d(recipient_pub_b64))).encrypt(data)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def unseal(priv_b64: str, ciphertext: bytes) -> bytes:
|
|
131
|
+
"""Open a sealed box with the recipient private key. Raises on wrong key / tampering."""
|
|
132
|
+
_require()
|
|
133
|
+
return SealedBox(PrivateKey(_b64d(priv_b64))).decrypt(ciphertext)
|