Compare commits
58 Commits
a938c19a82
...
cursor/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c38e36f685 | ||
|
|
50e8904f1c | ||
|
|
7e289fef2e | ||
|
|
e9a094b620 | ||
|
|
1299a6bb1c | ||
|
|
f220fd2210 | ||
|
|
fdeb881c83 | ||
|
|
08e9c22ccf | ||
|
|
e81d989f39 | ||
|
|
3eb7c6b93e | ||
|
|
6fa69aecaa | ||
|
|
640ef78711 | ||
|
|
938a0a721b | ||
|
|
2a0d414593 | ||
|
|
2469023083 | ||
|
|
f7fbe166e6 | ||
|
|
08bffbe9b4 | ||
|
|
eac852a515 | ||
|
|
5cdce1a5b0 | ||
|
|
80bd83ae0a | ||
|
|
ca49675f50 | ||
|
|
5e89bba78f | ||
|
|
339577a26c | ||
|
|
0e8acf5d59 | ||
|
|
d83224a62f | ||
|
|
4bfdc814e2 | ||
|
|
7e7682be47 | ||
|
|
4f007aeef9 | ||
|
|
b615acf582 | ||
|
|
7911223980 | ||
|
|
d151dd5484 | ||
|
|
2e696be80f | ||
|
|
ccb69c41e3 | ||
|
|
7f67e29d76 | ||
|
|
ee2711a38a | ||
|
|
4856749286 | ||
|
|
0d65ef3ea6 | ||
|
|
cdc9f11128 | ||
|
|
b547034741 | ||
|
|
f841dfaeed | ||
|
|
32514e84c9 | ||
|
|
af56dec7bd | ||
|
|
de6ce1d514 | ||
|
|
9abe83b5f4 | ||
|
|
c967e5cfc4 | ||
|
|
05c9e099e9 | ||
|
|
81719ed84b | ||
|
|
7414ce1e29 | ||
|
|
9bd15de65b | ||
|
|
69b0e726b8 | ||
|
|
c702a77c07 | ||
|
|
83e44d8312 | ||
|
|
de52ad7aa0 | ||
|
|
68e057209c | ||
|
|
7caf12980a | ||
|
|
5179806a67 | ||
|
|
98249e504a | ||
|
|
83b042d94b |
18
.agents/skills/claude-handoff/SKILL.md
Normal file
18
.agents/skills/claude-handoff/SKILL.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: claude-handoff
|
||||||
|
description: Hand the current conversation off to a fresh background agent that picks up the work immediately.
|
||||||
|
argument-hint: "What will the next session be used for?"
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Write a handoff summary of the current conversation so a fresh agent can continue the work. Instead of saving it, launch a background agent seeded with the summary as its prompt: `claude --bg --name "<descriptive name>" "<handoff summary>"`. It starts in the current working directory and returns immediately; the user manages it with `claude agents`.
|
||||||
|
|
||||||
|
Always pass `-n`/`--name` with a descriptive name (e.g. `--name "Fix login bug"`) — it sets the display name shown in the job list, session picker, and terminal title.
|
||||||
|
|
||||||
|
Include a "suggested skills" section in the summary, which suggests skills that the agent should invoke.
|
||||||
|
|
||||||
|
Do not duplicate content already captured in other artifacts (PRDs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead.
|
||||||
|
|
||||||
|
Redact any sensitive information, such as API keys, passwords, or personally identifiable information — the summary becomes the agent's prompt.
|
||||||
|
|
||||||
|
If the user passed arguments, treat them as a description of what the next session will focus on and tailor the summary accordingly.
|
||||||
89
.agents/skills/code-review/SKILL.md
Normal file
89
.agents/skills/code-review/SKILL.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: code-review
|
||||||
|
description: Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes — Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/PRD asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to "review since X".
|
||||||
|
---
|
||||||
|
|
||||||
|
Two-axis review of the diff between `HEAD` and a fixed point the user supplies:
|
||||||
|
|
||||||
|
- **Standards** — does the code conform to this repo's documented coding standards?
|
||||||
|
- **Spec** — does the code faithfully implement the originating issue / PRD / spec?
|
||||||
|
|
||||||
|
Both axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings.
|
||||||
|
|
||||||
|
The issue tracker should have been provided to you — run `/setup-matt-pocock-skills` if `docs/agents/issue-tracker.md` is missing.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### 1. Pin the fixed point
|
||||||
|
|
||||||
|
Whatever the user said is the fixed point — a commit SHA, branch name, tag, `main`, `HEAD~5`, etc. If they didn't specify one, ask for it.
|
||||||
|
|
||||||
|
Capture the diff command once: `git diff <fixed-point>...HEAD` (three-dot, so the comparison is against the merge-base). Also note the list of commits via `git log <fixed-point>..HEAD --oneline`.
|
||||||
|
|
||||||
|
Before going further, confirm the fixed point resolves (`git rev-parse <fixed-point>`) and the diff is non-empty. A bad ref or empty diff should fail here — not inside two parallel sub-agents.
|
||||||
|
|
||||||
|
### 2. Identify the spec source
|
||||||
|
|
||||||
|
Look for the originating spec, in this order:
|
||||||
|
|
||||||
|
1. Issue references in the commit messages (`#123`, `Closes #45`, GitLab `!67`, etc.) — fetch via the workflow in `docs/agents/issue-tracker.md`.
|
||||||
|
2. A path the user passed as an argument.
|
||||||
|
3. A PRD/spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature.
|
||||||
|
4. If nothing is found, ask the user where the spec is. If they say there isn't one, the **Spec** sub-agent will skip and report "no spec available".
|
||||||
|
|
||||||
|
### 3. Identify the standards sources
|
||||||
|
|
||||||
|
Anything in the repo that documents how code should be written, such as `CODING_STANDARDS.md` or `CONTRIBUTING.md`.
|
||||||
|
|
||||||
|
On top of whatever the repo documents, the Standards axis always carries the **smell baseline** below — a fixed set of Fowler code smells (_Refactoring_, ch.3) that applies even when a repo documents nothing. Two rules bind it:
|
||||||
|
|
||||||
|
- **The repo overrides.** A documented repo standard always wins; where it endorses something the baseline would flag, suppress the smell.
|
||||||
|
- **Always a judgement call.** Each smell is a labelled heuristic ("possible Feature Envy"), never a hard violation — and, like any standard here, skip anything tooling already enforces.
|
||||||
|
|
||||||
|
Each smell reads *what it is* → *how to fix*; match it against the diff:
|
||||||
|
|
||||||
|
- **Mysterious Name** — a function, variable, or type whose name doesn't reveal what it does or holds. → rename it; if no honest name comes, the design's murky.
|
||||||
|
- **Duplicated Code** — the same logic shape appears in more than one hunk or file in the change. → extract the shared shape, call it from both.
|
||||||
|
- **Feature Envy** — a method that reaches into another object's data more than its own. → move the method onto the data it envies.
|
||||||
|
- **Data Clumps** — the same few fields or params keep travelling together (a type wanting to be born). → bundle them into one type, pass that.
|
||||||
|
- **Primitive Obsession** — a primitive or string standing in for a domain concept that deserves its own type. → give the concept its own small type.
|
||||||
|
- **Repeated Switches** — the same `switch`/`if`-cascade on the same type recurs across the change. → replace with polymorphism, or one map both sites share.
|
||||||
|
- **Shotgun Surgery** — one logical change forces scattered edits across many files in the diff. → gather what changes together into one module.
|
||||||
|
- **Divergent Change** — one file or module is edited for several unrelated reasons. → split so each module changes for one reason.
|
||||||
|
- **Speculative Generality** — abstraction, parameters, or hooks added for needs the spec doesn't have. → delete it; inline back until a real need shows.
|
||||||
|
- **Message Chains** — long `a.b().c().d()` navigation the caller shouldn't depend on. → hide the walk behind one method on the first object.
|
||||||
|
- **Middle Man** — a class or function that mostly just delegates onward. → cut it, call the real target direct.
|
||||||
|
- **Refused Bequest** — a subclass or implementer that ignores or overrides most of what it inherits. → drop the inheritance, use composition.
|
||||||
|
|
||||||
|
### 4. Spawn both sub-agents in parallel
|
||||||
|
|
||||||
|
Send a single message with two `Agent` tool calls. Use the `general-purpose` subagent for both.
|
||||||
|
|
||||||
|
**Standards sub-agent prompt** — include:
|
||||||
|
|
||||||
|
- The full diff command and commit list.
|
||||||
|
- The list of standards-source files you found in step 3, **plus the smell baseline from step 3** pasted in full — the sub-agent has no other access to it.
|
||||||
|
- The brief: "Report — per file/hunk where relevant — (a) every place the diff violates a documented standard: cite the standard (file + the rule); and (b) any baseline smell you spot: name it and quote the hunk. Distinguish hard violations from judgement calls — documented-standard breaches can be hard, but baseline smells are always judgement calls, and a documented repo standard overrides the baseline. Skip anything tooling enforces. Under 400 words."
|
||||||
|
|
||||||
|
**Spec sub-agent prompt** — include:
|
||||||
|
|
||||||
|
- The diff command and commit list.
|
||||||
|
- The path or fetched contents of the spec.
|
||||||
|
- The brief: "Report: (a) requirements the spec asked for that are missing or partial; (b) behaviour in the diff that wasn't asked for (scope creep); (c) requirements that look implemented but where the implementation looks wrong. Quote the spec line for each finding. Under 400 words."
|
||||||
|
|
||||||
|
If the spec is missing, skip the Spec sub-agent and note this in the final report.
|
||||||
|
|
||||||
|
### 5. Aggregate
|
||||||
|
|
||||||
|
Present the two reports under `## Standards` and `## Spec` headings, verbatim or lightly cleaned. Do **not** merge or rerank findings — the two axes are deliberately separate (see _Why two axes_).
|
||||||
|
|
||||||
|
End with a one-line summary: total findings per axis, and the worst issue _within each axis_ (if any). Don't pick a single winner across axes — that's the reranking the separation exists to prevent.
|
||||||
|
|
||||||
|
## Why two axes
|
||||||
|
|
||||||
|
A change can pass one axis and fail the other:
|
||||||
|
|
||||||
|
- Code that follows every standard but implements the wrong thing → **Standards pass, Spec fail.**
|
||||||
|
- Code that does exactly what the issue asked but breaks the project's conventions → **Spec pass, Standards fail.**
|
||||||
|
|
||||||
|
Reporting them separately stops one axis from masking the other.
|
||||||
12
.agents/skills/research/SKILL.md
Normal file
12
.agents/skills/research/SKILL.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: research
|
||||||
|
description: Investigate a question against high-trust primary sources and capture the findings as a Markdown file in the repo. Use when the user wants a topic researched, docs or API facts gathered, or reading legwork delegated to a background agent.
|
||||||
|
---
|
||||||
|
|
||||||
|
Spin up a **background agent** to do the research, so you keep working while it reads.
|
||||||
|
|
||||||
|
Its job:
|
||||||
|
|
||||||
|
1. Investigate the question against **primary sources** — official docs, source code, specs, first-party APIs — not a secondary write-up of them. Follow every claim back to the source that owns it.
|
||||||
|
2. Write the findings to a single Markdown file, citing each claim's source.
|
||||||
|
3. Save it where the repo already keeps such notes; match the existing convention, and if there is none, put it somewhere sensible and say where.
|
||||||
101
.agents/skills/wayfinder/SKILL.md
Normal file
101
.agents/skills/wayfinder/SKILL.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: wayfinder
|
||||||
|
description: Plan a huge chunk of work — more than one agent session can hold — as a shared map of investigation tickets on your issue tracker, and resolve them one at a time until the way to the goal is clear.
|
||||||
|
---
|
||||||
|
|
||||||
|
A loose idea has arrived — too big for one agent session, and wrapped in fog: the route from here to a plan isn't visible yet. This skill charts it as a **shared map** on the repo's issue tracker, then works its tickets one at a time. The map is domain-agnostic — engineering work, course content, whatever fits the shape.
|
||||||
|
|
||||||
|
## Refer by name
|
||||||
|
|
||||||
|
Every map and ticket is an issue, so it has a **name** — its title. In everything the human reads — narration, the map's Decisions-so-far — refer to it by that name, never by a bare id, number, or slug. A wall of `#42, #43, #44` is illegible; names read at a glance. The id and URL don't vanish — a name wraps its link — but they ride *inside* the name, never stand in for it.
|
||||||
|
|
||||||
|
## The Map
|
||||||
|
|
||||||
|
The map is a single issue on this repo's issue tracker, labelled `wayfinder:map` — the canonical artifact. Its tickets are child issues of the map.
|
||||||
|
|
||||||
|
The map is an **index**, not a store. It lists the decisions made and points at the tickets that hold their detail; a decision lives in exactly one place — its ticket — so the map never restates it, only gists it and links.
|
||||||
|
|
||||||
|
**Where the map, its child tickets, blocking, and frontier queries physically live is tracker-specific.** Consult `docs/agents/issue-tracker.md` (the "Wayfinding operations" section) for how _this_ repo expresses them. If that doc is absent, default to the local-markdown tracker.
|
||||||
|
|
||||||
|
### The map body
|
||||||
|
|
||||||
|
The whole map at low resolution, loaded once per session. Open tickets are **not** listed — they are open child issues, found by query.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
<domain; skills every session should consult; standing preferences for this effort>
|
||||||
|
|
||||||
|
## Decisions so far
|
||||||
|
|
||||||
|
<!-- the index — one line per closed ticket: enough to judge relevance, then zoom the link for the detail the ticket holds -->
|
||||||
|
|
||||||
|
- [<closed ticket title>](link) — <one-line gist of the answer>
|
||||||
|
|
||||||
|
## Fog
|
||||||
|
|
||||||
|
<!-- see "Fog of war" for what belongs here -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tickets
|
||||||
|
|
||||||
|
Each ticket is a **child issue** of the map; the tracker's issue id is its identity. Its body is the question, sized to one 100K token agent session:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Question
|
||||||
|
|
||||||
|
<the decision or investigation this ticket resolves>
|
||||||
|
```
|
||||||
|
|
||||||
|
Each ticket carries a `wayfinder:<type>` label — one of `research`, `prototype`, `grilling`, `task` (see [Ticket Types](#ticket-types)).
|
||||||
|
|
||||||
|
A session **claims** a ticket by assigning it to the dev driving the map, **first**, before any work, so concurrent sessions skip it. That assignee _is_ the claim: an open, unassigned ticket is unclaimed.
|
||||||
|
|
||||||
|
Blocking uses the tracker's **native** dependency relationship — essential because it renders the frontier _visually_ in the tracker's own UI, so the human sees what's takeable without opening the map. Only a tracker that lacks native blocking falls back to a body convention. A ticket is **unblocked** when every ticket blocking it is closed; the **frontier** is the open, unblocked, unclaimed children — the edge of the known.
|
||||||
|
|
||||||
|
The answer isn't part of the body — it's recorded on resolution (see [Work through the map](#work-through-the-map)). Assets created while resolving a ticket are linked from the issue, not pasted in.
|
||||||
|
|
||||||
|
## Ticket Types
|
||||||
|
|
||||||
|
- **Research**: Reading documentation, third-party APIs, or local resources like knowledge bases. Creates a markdown summary as a linked asset. Use when knowledge outside the current working directory is required.
|
||||||
|
- **Prototype**: Raise the fidelity of the discussion by making a cheap, rough, concrete artifact to react to — an outline, a rough take, a stub, or UI/logic code via the /prototype skill. Links the prototype as an asset. Use when "how should it look" or "how should it behave" is the key question.
|
||||||
|
- **Grilling**: Conversation with the agent. Uses the /grilling and /domain-modeling skills. Asks one question at a time. The default case.
|
||||||
|
- **Task**: Literal manual work that must be done before the discussion can move forward — nothing to decide, prototype, or research. Moving data, signing up for a service, provisioning access. The agent automates it where it can; otherwise it hands the human a precise checklist. Resolved when the work is done; the answer records what was done and any resulting facts (credentials location, new URLs, row counts) later tickets depend on.
|
||||||
|
|
||||||
|
## Fog of war
|
||||||
|
|
||||||
|
The map is _deliberately_ incomplete: don't chart what you can't yet see. Beyond the tickets lies fog — the dim view of decisions and investigations you can tell are coming but can't yet pin down, because they hang on questions still open. Resolving a ticket clears the fog ahead of it, graduating whatever's now specifiable into fresh tickets — one at a time, until the way to the goal is clear and no tickets remain.
|
||||||
|
|
||||||
|
The map's **Fog** section is where that dim view is written down: the suspected question, the area to revisit later, the risk you're deferring. Write as loosely or as fully as the view allows; it doubles as a signpost for collaborators reading where the effort is headed.
|
||||||
|
|
||||||
|
**Fog or ticket?** The test is whether you can state the question precisely now — _not_ whether you can answer it now.
|
||||||
|
|
||||||
|
- **Ticket when** the question is already sharp — even if it's blocked and you can't act on it yet.
|
||||||
|
- **Fog when** you can't yet phrase it that sharply. Don't pre-slice fog into ticket-sized pieces: it's coarser than a ticket, and one patch may graduate into several tickets, or none, once the frontier reaches it.
|
||||||
|
|
||||||
|
Fog excludes only what's already decided (that's Decisions so far) and what's already a ticket.
|
||||||
|
|
||||||
|
## Invocation
|
||||||
|
|
||||||
|
Two modes. Either way, **never resolve more than one ticket per session.**
|
||||||
|
|
||||||
|
### Chart the map
|
||||||
|
|
||||||
|
User invokes with a loose idea.
|
||||||
|
|
||||||
|
1. Run a `/grilling` and `/domain-modeling` session to surface the open decisions.
|
||||||
|
2. **Create the map** (label `wayfinder:map`): Notes filled in, Decisions-so-far empty, Fog sketched.
|
||||||
|
3. **Create the tickets you can specify now** as child issues of the map — then wire blocking edges in a **second pass** (issues need ids before they can reference each other). Wiring sorts them into the frontier and the blocked; everything you can't yet specify stays in the Fog.
|
||||||
|
4. Stop — charting the map is one session's work; do not also resolve tickets.
|
||||||
|
|
||||||
|
### Work through the map
|
||||||
|
|
||||||
|
User invokes with a map (URL or number). A ticket is **optional** — without one, you pick the next decision, not the user.
|
||||||
|
|
||||||
|
1. Load the **map** — the low-res view, not every ticket body.
|
||||||
|
2. Choose the ticket. If the user named one, use it. Otherwise take the first frontier ticket in order. **Claim it**: assign it to yourself before any work.
|
||||||
|
3. Resolve it — **zoom as needed**: fetch the full body of any related or closed ticket on demand; invoke the skills the `## Notes` block names. If in doubt, use `/grilling` and `/domain-modeling`.
|
||||||
|
4. Record the resolution: post the answer as a **resolution comment**, **close** the issue, and **append a context pointer** to the map's Decisions-so-far.
|
||||||
|
5. Add newly-surfaced tickets (create-then-wire); graduate any fog the answer has made specifiable, clearing each graduated patch from the Fog so it lives only as its new ticket. If the decision invalidates other parts of the map, update or delete those tickets.
|
||||||
|
|
||||||
|
The user may run unblocked tickets in parallel, so expect other sessions to be editing the tracker concurrently.
|
||||||
45
.agents/skills/wizard/SKILL.md
Normal file
45
.agents/skills/wizard/SKILL.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: wizard
|
||||||
|
description: Generate an interactive bash wizard that walks a human through a manual procedure — third-party setup, a one-off migration, an A→B state transition — opening URLs, capturing values, confirming each step, and writing .env files and GitHub Actions secrets.
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Wizard
|
||||||
|
|
||||||
|
A **wizard** is a bash script that walks a human, step by step, through a manual procedure that's tedious to do by hand and tedious to re-explain to an AI every time. It opens each URL, says exactly what to click and copy, captures the values, writes them where they belong (`.env`, GitHub secrets), confirms at every stage, and shows how much is left. It might configure third-party services, run a one-off migration, or move the project from one state to another.
|
||||||
|
|
||||||
|
The delightful UX is already solved by [template.sh](template.sh) — progress with time-remaining, confirmation gates, cross-platform URL opening (including WSL), hidden secret entry, idempotent `.env` upserts, `gh secret`/`gh variable` writes, and a closing summary. **Your job is only to scope the procedure and author its stages.** The library above the `STAGES` marker is identical in every wizard; that consistency is the point — never hand-edit it.
|
||||||
|
|
||||||
|
A wizard is ephemeral by default — built for one run, saved to a scratch or `scripts/` path, deleted when the job's done. Commit it only when the user wants a repeatable setup path that should live in the repo.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### 1. Scope the procedure
|
||||||
|
|
||||||
|
Work out every manual step the human must take and every value that gets captured along the way. Read the repo first — don't ask cold:
|
||||||
|
|
||||||
|
- For setup: `.env`, `.env.example`, `.env.*`, `README`, `docker-compose*`, framework config, and `.github/workflows/*` (every `secrets.*` / `vars.*` reference is a value the wizard must produce).
|
||||||
|
- For a migration or transition: the current state, the target state, and the irreversible actions between them.
|
||||||
|
|
||||||
|
Then show the user the ordered list of stages and the values each produces, and confirm — they may add, drop, or reorder.
|
||||||
|
|
||||||
|
**Done when:** every stage is named in order, and for each captured value you know (a) where the human gets it, (b) where it's written (`.env`, a GitHub secret, both, or nowhere — some stages are pure actions), and (c) whether it's secret (hidden entry) or public.
|
||||||
|
|
||||||
|
### 2. Map each stage's journey
|
||||||
|
|
||||||
|
For each stage, write the precise path a human follows: which URL to open, what to do there, where a value is shown, which variable it fills — e.g. "Dashboard → Developers → API keys → Reveal test key → copy". Where you don't actually know the current UI or the exact command, say so and ask the user or check the docs — never invent steps that may not exist.
|
||||||
|
|
||||||
|
**Done when:** every stage traces to concrete instructions a stranger could follow.
|
||||||
|
|
||||||
|
### 3. Author the wizard
|
||||||
|
|
||||||
|
Copy `template.sh` to the target path. Replace the example stage with one `stage` per step, in dependency order. Use the library helpers — `stage`, `say`/`step`, `open_url`, `ask`/`ask_secret`, `write_env`, `set_secret`/`set_var`, `pause`/`confirm` — and set `TOTAL_STAGES` and `TOTAL_MINUTES` to honest estimates (this drives the time-remaining display).
|
||||||
|
|
||||||
|
Hold the bar the template sets: open the URL before asking for its value, use `ask_secret` for anything secret, `write_env` every persisted value, `set_secret` only the values CI actually needs, and `confirm` before any irreversible action. Each `stage` clears the screen so only the current step is visible — keep a stage to one focused task so nothing the human needs scrolls away. Don't touch the library above the marker.
|
||||||
|
|
||||||
|
### 4. Verify and hand off
|
||||||
|
|
||||||
|
- `bash -n <script>`; run `shellcheck` if available.
|
||||||
|
- `chmod +x <script>`.
|
||||||
|
- Don't run it end-to-end yourself — it opens browsers and blocks on human input. Trace it statically instead: every value from step 1 is captured and lands where step 1 said, and every `set_secret` name exactly matches a `secrets.*` reference in CI.
|
||||||
|
- Tell the user how to run it. If it's a repeatable setup path, commit it and link it from the README so the next person runs the script instead of asking an AI.
|
||||||
BIN
.billing.sqlite
Normal file
BIN
.billing.sqlite
Normal file
Binary file not shown.
@@ -2,4 +2,6 @@
|
|||||||
|
|
||||||
- [Product selling points](product-selling-points.md) — key differentiators and landing page angles for neuron-tai
|
- [Product selling points](product-selling-points.md) — key differentiators and landing page angles for neuron-tai
|
||||||
- [User profile](user-profile.md) — who Dobromir is and how to work with him
|
- [User profile](user-profile.md) — who Dobromir is and how to work with him
|
||||||
- [Project status](project-status.md) — 29/30 done; US-030 (manual route + hop benchmark) is the only open story
|
- [Project status](project-status.md) — 35/35 stories done; alpha hardening next
|
||||||
|
- **Alpha hardening** — `.scratch/alpha-hardening/` (22 issues, ADRs 0016–0019, [README](../.scratch/alpha-hardening/README.md), [handoff](../.scratch/alpha-hardening/handoff.md))
|
||||||
|
- [Alpha hardening navigation](alpha-hardening-navigation.md) — locked fraud/auth decisions, Bucket-1 order, handoff pointers
|
||||||
|
|||||||
32
.claude/memory/alpha-hardening-navigation.md
Normal file
32
.claude/memory/alpha-hardening-navigation.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: alpha-hardening-navigation
|
||||||
|
description: Where the alpha-hardening plan lives, locked design decisions, and implementation order
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Active workstream (started 2026-07-04): alpha hardening of the money/trust path. Full handoff at `/mnt/c/Users/popov/Downloads/neuron-tai-alpha-handoff-2026-07-04.md` (note: its "planning artifacts missing" section is stale — ADRs 0016–0019 and `.scratch/alpha-hardening/issues/` were created in commit 68e0572).
|
||||||
|
|
||||||
|
**Navigation:** `.scratch/alpha-hardening/README.md` = index + phase order; `research-verifiable-inference.md` §8 = layered fraud scheme, §9 = build-vs-adopt; ADR-0018 = flagship fraud design; `docs/agents/issue-tracker.md` = issue conventions (active work in `.scratch/<slug>/`).
|
||||||
|
|
||||||
|
**Locked decisions (do not re-derive):** TOPLOC ADOPT (`pip install toploc`, teacher-forced prefill, one canonical precision per model); audit 5% default escalating on anomaly/low-rep/disputes; blame via on-demand per-hop activation commitments + bisection (fixes validator `_final_text_node` bug — it blames only the last hop); reputation = persisted graduated ×0.8-per-strike multiplier affecting routing + audit rate; full pending forfeiture stays the primary penalty; accounting becomes tracker-authoritative (count tokens from the proxied stream, work units from tracker-assigned spans — node self-reports are untrusted); strikes/bans/reputation must survive restart (RegistryWallet is RAM-only today); multi-tracker money fixes (C2/C3/C4/A3/H1) designed in ADR-0019 but implementation deferred.
|
||||||
|
|
||||||
|
**Implementation state (2026-07-05):** Alpha-scoped blocker implementation is done: auth boundary + gossip + validator service token (02/01/20), persist strike/ban/reputation (05), starting credit 0 + spend cap (03), tracker-authoritative accounting (04), wallet binding proof (11), and fraud arc (06–10). `.scratch/alpha-hardening/issues/` now has 16 `done` and 6 `ready-for-human` items.
|
||||||
|
|
||||||
|
**Auth foundation now available (commit 81719ed):** `packages/tracker/meshnet_tracker/auth.py` = hive HMAC (`sign_hive_request`/`verify_hive_request`, X-Meshnet-Hive-Signature/Timestamp, 300s skew) + `is_validator_token`. In the handler: `_require_role("admin"|"validator")`, `_resolve_identity()` (validator token / admin session / client-key→no-role), `_read_hive_authenticated_body()`. `TrackerServer(validator_service_token=, hive_secret=)` also read from MESHNET_VALIDATOR_SERVICE_TOKEN / MESHNET_HIVE_SECRET; CLI `--validator-service-token` / `--hive-secret`. Outgoing gossip signed via `_push_to_peers`. Tests use these fixtures — reuse the pattern in 05/03/04.
|
||||||
|
|
||||||
|
**Remaining work classification:** 12–15 are multi-tracker money/Raft ordering hardening deferred beyond single-settlement alpha; 17 needs human approval for canonical duplicate US-020 renumbering. Full pytest suite re-verified 2026-07-06: 317 passed, 3 skipped, clean.
|
||||||
|
|
||||||
|
**Launch-readiness grilling (2026-07-06):** Locked launch plan — devnet dev/test run now, then **real mainnet SOL/USDT** (not devnet, not a new public token) for the first cohort: friends (API clients) + hired VPS/VPC hosts (our own test infra, not third-party volunteers — stake-free, risk-free if something breaks, not a long-term topology). Pricing: clients are the only party spending real money; nodes only accumulate off-chain credit and get paid in batches (30min dev / 24h later) — a failed distribution leaves funds parked, not lost, so mainnet-vs-devnet mixups are lower-risk than initially assumed. TAI token: do NOT issue/list now — ADR-0002 already locks listing behind $50k volume + 25 nodes/15 wallets plus an unresolved securities-review gate; only a dormant mainnet mint (cheap, ~few $ SOL) for name/branding reservation is in scope, bundled with treasury-key work, not before it. Treasury custody: bare keypair file (current runbook 02) is not acceptable for real funds — plan is **free native SPL multisig** (`spl-token create-multisig`, no protocol fee unlike Squads' 0.5 SOL), 2-of-3 signers, at least one cold/offline, others one-per-hired-VPS-provider to avoid correlated compromise (not yet built — ops task, no issue filed). Stake/slash asymmetry (registry/slash is a local Python adapter per ADR-0007, not on-chain) accepted for now since hired hosts are our own infra and friends aren't node operators — revisit before opening to real third-party node operators. A mainnet-vs-devnet boot guardrail was proposed and explicitly declined by the owner given the safe-by-default money flow above.
|
||||||
|
|
||||||
|
**Two new issues from this session, both `ready-for-agent`:**
|
||||||
|
- **21 — Honest-noise calibration corpus** (`.scratch/alpha-hardening/issues/21-honest-noise-calibration-corpus.md`) rescoped from "prod gate" to a **hard alpha-release blocker**. Confirmed by code read: `verify_activation_proofs()` (`packages/validator/meshnet_validator/audit.py:94-127`) returns bool only, no raw divergence value; fleet-dispatch exists but wrong shape (`server.py:2998-3104`, pinned routes + latency, not full-fleet + TOPLOC divergence); storage wrong shape (`registry_events` has no divergence/hardware columns). Three-part build: (1) surface raw TOPLOC distance from audit.py, (2) extend dispatch to hit every registered node with fixed prompt/seed, (3) new SQLite table keyed by node+GPU+dtype. Small-fleet exception granted (N = actual hired-VPS fleet size). Hired VPS hosts stay stake-free until this closes.
|
||||||
|
- **23 — Dynamic HF-benchmarked pricing** (`.scratch/alpha-hardening/issues/23-dynamic-hf-pricing.md`), high priority but not a release blocker. Pricing today is 100% static (`DEFAULT_PRICE_PER_1K_TOKENS = 0.02`, `billing.py:21`; `model_presets.json` has no per-model price). Target: 80% of cheapest comparable provider on `https://huggingface.co/inference/models` (per-provider-per-model marketplace, `?search=` query param works, no confirmed JSON API — plain scrape attempted first, escalate to headless browser only if the table isn't in raw HTML). Human-verified `hf_aliases` + `hf_verified_match_note` (params/quantization) per model, not auto-discovered matching. Reuses the `_settlement_loop` daemon-thread pattern for a daily refresh; falls back silently to the static default on any failure.
|
||||||
|
|
||||||
|
Both are already migrated into `.scratch/alpha-hardening/prd.json` (AH-021 updated, AH-023 added) and the README index — ready for Ralph to pick up unattended.
|
||||||
|
|
||||||
|
**Ralph note:** `scripts/ralph_progress.py` tracks `docs/prd.json` (35/35 done) and does NOT see `.scratch/alpha-hardening/issues/`. No ralph loop is running and no `.ralph-tui/` state exists. `.scratch/alpha-hardening/prd.json` now has 23 stories (AH-001…AH-023); point Ralph at that file for the alpha-hardening branch. Do NOT use `ralph auto --parallel` on server.py-touching issues — 21 and 23 both touch `server.py`/`billing.py`/`audit.py`; if run in the same Ralph pass, run them serially, not in parallel (merge-conflict risk, same lesson as 03/04 previously).
|
||||||
|
|
||||||
|
**Why:** three audits agreed the alpha blockers are unauthenticated gossip (anyone can inject billing events), the free-credit faucet, and ephemeral bans.
|
||||||
|
**How to apply:** work test-first per issue acceptance criteria; use `.venv`; `cryptography` belongs in node deps (wallet.py imports it — causes many of the 24 "failures" in a fresh env). See [[project-status]] and [[autonomous-work-style]].
|
||||||
@@ -16,15 +16,33 @@ All 35 user stories in docs/prd.json are done (35/35), including the reward-syst
|
|||||||
- **TrackerServer threads**: deposit watcher (exactly-once via deposit-<sig> event ids) + leader-only settlement loop (threshold OR max-period, dust floor, resend-by-settlement-id → no double-pay).
|
- **TrackerServer threads**: deposit watcher (exactly-once via deposit-<sig> event ids) + leader-only settlement loop (threshold OR max-period, dust floor, resend-by-settlement-id → no double-pay).
|
||||||
- **Forfeiture penalty**: validator forfeits pending balance + strike; 3 strikes ban; probation redirects shares to protocol cut. Math in packages/validator/README.md.
|
- **Forfeiture penalty**: validator forfeits pending balance + strike; 3 strikes ban; probation redirects shares to protocol cut. Math in packages/validator/README.md.
|
||||||
- **Web dashboard**: GET /dashboard on any tracker, embedded dashboard.html, 4s polling.
|
- **Web dashboard**: GET /dashboard on any tracker, embedded dashboard.html, 4s polling.
|
||||||
|
- **Observed routing throughput**: tracker records rolling observed tokens/sec per `(node_id, model)` from completed proxied inference requests, exposes it via `/v1/stats` and `/v1/network/map`, shows it on the dashboard, and prefers observed per-model TPS over startup benchmark for routing when samples exist.
|
||||||
|
|
||||||
Suite: 222 passed, 3 skipped (openai/langchain packages missing in .venv — pre-existing).
|
Suite: 222 passed, 3 skipped (openai/langchain packages missing in .venv — pre-existing).
|
||||||
|
|
||||||
**Why:** design locked in ADR-0015 (USDT custodial settlement; TAI deferred, protocol cut = future TAI liquidity).
|
**Why:** design locked in ADR-0015 (USDT custodial settlement; TAI deferred, protocol cut = future TAI liquidity).
|
||||||
**How to apply:** next steps are live devnet verification (run devnet_setup.py, start tracker with --solana-rpc-url/--usdt-mint/--treasury-keypair --billing-db), then the TAI mint when volume justifies it. Work not yet committed to git as of session end — check git status.
|
**How to apply:** next steps are live devnet verification (run devnet_setup.py, start tracker with --solana-rpc-url/--usdt-mint/--treasury-keypair --billing-db), then the TAI mint when volume justifies it. Work not yet committed to git as of session end — check git status.
|
||||||
|
|
||||||
|
## Alpha hardening (2026-07-05)
|
||||||
|
|
||||||
|
Implementation complete for alpha-scoped blockers in `.scratch/alpha-hardening/`: 16/22 issues are `done`, including auth/gossip/service-token, persisted strike/ban/reputation, zero starting credit + spend cap, tracker-authoritative accounting, wallet binding proof, TOPLOC audit primitive, hop bisection, reputation scoring/routing, adaptive audits, and penalty wiring. Remaining 6/22 are `ready-for-human` / post-alpha or ops-gated: 12–15 multi-tracker money/Raft ordering hardening, 17 duplicate US-020 renumbering approval, and 21 honest-noise calibration corpus before production audit thresholds. Current verification: `uv run pytest -k 'not test_legacy_start_without_port_uses_next_available_port'` passes 316, skips 3; full `uv run pytest` has one environmental failure while local `meshnet-node` PID 1263451 occupies port 7000.
|
||||||
|
|
||||||
|
Historical handoff note: `/mnt/c/Users/popov/Downloads/neuron-tai-alpha-handoff-2026-07-04.md` is useful for navigation and original audit context, but it predates the completed `.scratch/alpha-hardening/` planning artifacts. Treat its "missing ADR/issues/README" statements as stale; prefer `.scratch/alpha-hardening/README.md` and `.scratch/alpha-hardening/handoff.md` for current task order.
|
||||||
|
|
||||||
## Windows CUDA node (working as of 2026-07-01)
|
## Windows CUDA node (working as of 2026-07-01)
|
||||||
- miniforge3 base env, torch 2.7.1+cu118, torchvision 0.22.x+cu118
|
- miniforge3 base env, torch 2.7.1+cu118, torchvision 0.22.x+cu118
|
||||||
- RTX 4060 Laptop GPU, 8 GB VRAM, benchmark index ~11,200
|
- RTX 4060 Laptop GPU, 8 GB VRAM, benchmark index ~11,200
|
||||||
- Run: `meshnet-node start --tracker https://ai.neuron.d-popov.com --model Qwen/Qwen2.5-0.5B-Instruct`
|
- Run: `meshnet-node start --tracker https://ai.neuron.d-popov.com --model Qwen/Qwen2.5-0.5B-Instruct`
|
||||||
- Known: tracker registration fails with `http://` — must use `https://`
|
- Known: tracker registration fails with `http://` — must use `https://`
|
||||||
- pynvml deprecation warning is harmless (use nvidia-ml-py to silence it)
|
- pynvml deprecation warning is harmless (use nvidia-ml-py to silence it)
|
||||||
|
|
||||||
|
## Model cache layout (2026-07-07)
|
||||||
|
- Node downloads now cache files directly under `<download_dir>/<model>/`, not `<model>/layers_<start>-<end>/`, so a wider cached layer assignment can satisfy a later narrower assignment without duplicate shard folders.
|
||||||
|
- Downloader checks tracker-advertised `files` + `file_sizes` before peer/HF download; complete local files return immediately and preserve any extra files already in the model folder.
|
||||||
|
- Verification: downloader/startup targeted subset passes (`pytest tests/test_node_startup.py -k "download_shard or same_shard"`). Full `tests/test_node_startup.py` has 46 passed and 4 unrelated Windows chmod/path separator failures.
|
||||||
|
- Live Windows confirmation: `meshnet-node start --tracker http://192.168.0.179:8080 --model Qwen3.6-35B-A3B` reuses `F:\_STORAGE\models\qwen3.6-35b-a3b`, prints `Cached at`, registers, and reaches ready as node `5gMLrmyB-26b1f8a4204a`.
|
||||||
|
- Follow-up fix: preset-model startup now starts the heartbeat thread after registration; without this, the node appeared briefly on the dashboard and was purged on first inference/route after heartbeat expiry. Tracker dashboard now has a "Console output" panel backed by `/v1/console` for node register/expiry, routing failures, and proxy events.
|
||||||
|
- Qwen3.6-35B-A3B reserve-based split is expected: an 79 GB CPU node may be assigned layers 0-36, and a second node fills 37-39. Do not "fix" this by bypassing the 20% assignment reserve unless the shard-planning policy changes.
|
||||||
|
- Route hardening: tracker chat proxy and `/v1/route` diagnostics now use alias-aware preset node matching for split Qwen3.6 routes; dashboard derives grouped inference history from proxy route/complete console events and shows observed TPS after completion.
|
||||||
|
- Live proxy hardening: model lookup trims outer whitespace before alias matching (`qwen3.6-35b-a3b ` resolves), and tracker route logs/dashboard queue depth combine heartbeat queue with tracker-local proxy in-flight counts so Postman-style bursts no longer show every selected route as queue `0`.
|
||||||
|
- Split-shard streaming hardening: Qwen3.6-style distributed generation now emits SSE chunks token-by-token from the head node instead of buffering all generated text until completion. Tracker direct/relay stream proxy logs `proxy progress` with live tokens/TPS, dashboard Inference history shows currently processing requests with live TPS/tokens/queue, and relay stream completion no longer references an undefined `session_id`.
|
||||||
|
|||||||
@@ -5,3 +5,9 @@ MESHNET_CONTRACT_ADAPTER=solana-testnet
|
|||||||
MESHNET_REGISTRY_PROGRAM_ID=
|
MESHNET_REGISTRY_PROGRAM_ID=
|
||||||
MESHNET_PAYMENT_PROGRAM_ID=
|
MESHNET_PAYMENT_PROGRAM_ID=
|
||||||
MESHNET_SETTLEMENT_PROGRAM_ID=
|
MESHNET_SETTLEMENT_PROGRAM_ID=
|
||||||
|
|
||||||
|
# MESHNET_DOWNLOAD_DIR=
|
||||||
|
# HF_TOKEN=
|
||||||
|
# URL_TRACKER=
|
||||||
|
# DEFAULT_MODEL=
|
||||||
|
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -11,3 +11,12 @@ dist/
|
|||||||
|
|
||||||
# Ralph local runtime state
|
# Ralph local runtime state
|
||||||
.ralph-tui/
|
.ralph-tui/
|
||||||
|
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.testnet
|
||||||
|
.rocm-local/*
|
||||||
|
billing.sqlite
|
||||||
|
.pytest-tmp/*
|
||||||
99
.scratch/alpha-hardening/README.md
Normal file
99
.scratch/alpha-hardening/README.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Alpha hardening — planning index
|
||||||
|
|
||||||
|
Pre-release alpha audit + grilling (2026-07-04). Bucket 1 trust-boundary blockers + fraud arc: **done** (16/22 original issues). Bucket 2 (12-15, multi-tracker) and 17 (doc dedup) remain deferred/human-gated — not launch blockers.
|
||||||
|
|
||||||
|
**Launch-readiness grilling (2026-07-06):** locked plan is devnet dev/test run now, then real mainnet SOL/USDT for the first cohort — friends (API clients) + hired VPS/VPC hosts (own test infra, not third-party volunteers, stake-free). No new public token; TAI stays dormant per ADR-0002's existing volume/legal gates. Two new issues came out of this session:
|
||||||
|
|
||||||
|
- **[21 — Honest-noise calibration corpus](./issues/21-honest-noise-calibration-corpus.md)** — rescoped from "prod gate" to a hard **alpha-release blocker**. `Status: ready-for-human` — engineering (audit.py raw divergence, tracker dispatch endpoint, SQLite corpus, p99 envelope) done 2026-07-06; blocked on a human running the calibration job against the real hired-VPS fleet before launch.
|
||||||
|
- **[23 — Dynamic HF-benchmarked pricing](./issues/23-dynamic-hf-pricing.md)** — new, high priority but not a release blocker. `Status: done` — engineering complete 2026-07-06 (hf_pricing.py, opt-in daily refresh loop, GET /v1/pricing/hf/history); real `hf_aliases` curation per model is a follow-up human sign-off, not a completion blocker.
|
||||||
|
|
||||||
|
Locked scope: one settlement tracker, open node join, devnet mock-USDT, reputation carries forward → fraud must be bounded. See [ADR-0016](../../docs/adr/0016-alpha-scope-and-known-limitations.md).
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
| Path | Status |
|
||||||
|
|---|---|
|
||||||
|
| [research-verifiable-inference.md](./research-verifiable-inference.md) | Complete — SOTA research, §8 layered scheme, TOPLOC adopt |
|
||||||
|
| [handoff.md](./handoff.md) | Session handoff — locked decisions, env notes |
|
||||||
|
| [docs/adr/0016–0019](../../docs/adr/) | Alpha scope, auth, fraud, multi-tracker design |
|
||||||
|
| [issues/](./issues/) | 22 work items (Buckets 1–3) |
|
||||||
|
|
||||||
|
## ADRs (this feature)
|
||||||
|
|
||||||
|
| ADR | Title |
|
||||||
|
|---|---|
|
||||||
|
| [0016](../../docs/adr/0016-alpha-scope-and-known-limitations.md) | Alpha scope & known limitations |
|
||||||
|
| [0017](../../docs/adr/0017-tracker-authentication-and-authorization.md) | Tracker authentication & authorization |
|
||||||
|
| [0018](../../docs/adr/0018-fraud-detection-verification-and-reputation.md) | Fraud detection, verification & reputation (flagship) |
|
||||||
|
| [0019](../../docs/adr/0019-money-path-consistency-multi-tracker.md) | Money-path consistency — design accepted, impl deferred |
|
||||||
|
| [0002](../../docs/adr/0002-dual-token-payment-model.md) | Amended — settlement superseded by 0015 |
|
||||||
|
| [0010](../../docs/adr/0010-p2p-gossip-and-nat-relay.md) | Amended — TLS alpha reality (relay only) |
|
||||||
|
|
||||||
|
## Recommended implementation order
|
||||||
|
|
||||||
|
**Implement Bucket 1 first.** Fraud arc depends on **auth foundation (02 + 20)**, **hive gossip enforcement (01)**, and **persistence (05)**.
|
||||||
|
|
||||||
|
### Phase 1 — Trust boundary (alpha blockers)
|
||||||
|
|
||||||
|
| Order | Issue | ID | Depends on |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | [Unified auth boundary](./issues/02-a2-unified-auth-boundary.md) + [Validator service token](./issues/20-validator-service-token.md) | A2, — | — |
|
||||||
|
| 2 | [C1 hive gossip auth enforcement](./issues/01-c1-gossip-auth.md) | C1 | 02 |
|
||||||
|
| 3 | [Persist strike/ban/reputation](./issues/05-a1-a5-persist-strike-ban-reputation.md) | A1/A5 | 02 |
|
||||||
|
| 4 | [Starting credit 0 + spend cap](./issues/03-c5-starting-credit-zero.md) | C5, M1 | 02 |
|
||||||
|
| 5 | [Tracker-authoritative accounting](./issues/04-h2-tracker-authoritative-accounting.md) | H2 | 02 |
|
||||||
|
| 6 | [Wallet binding proof](./issues/11-c6-wallet-binding-proof.md) | C6 | 02, 03 |
|
||||||
|
|
||||||
|
### Phase 2 — Fraud arc (after Phase 1)
|
||||||
|
|
||||||
|
| Order | Issue | Depends on |
|
||||||
|
|---|---|---|
|
||||||
|
| 6 | [TOPLOC integration](./issues/06-fraud-toploc-integration.md) | 05 |
|
||||||
|
| 7 | [Commitment + bisection blame](./issues/07-fraud-commitment-bisection-blame.md) | 06 |
|
||||||
|
| 8 | [Reputation model](./issues/08-fraud-reputation-model-persistence.md) | 05, 07 |
|
||||||
|
| 9 | [Routing + adaptive audit](./issues/09-fraud-reputation-routing-adaptive-audit.md) | 08 |
|
||||||
|
| 10 | [Penalty calibration wiring](./issues/10-fraud-penalty-calibration-wiring.md) | 07, 08, 02 |
|
||||||
|
|
||||||
|
**Prod gate:** [21 honest-noise calibration corpus](./issues/21-honest-noise-calibration-corpus.md) must complete before enabling production TOPLOC audit thresholds (issues 09–10 in prod). Dev/staging TOPLOC wiring (06–08) may proceed in parallel.
|
||||||
|
|
||||||
|
### Phase 3 — Bucket 2 (post-alpha, design tracked)
|
||||||
|
|
||||||
|
| Issue | ADR |
|
||||||
|
|---|---|
|
||||||
|
| [12 C2 on-chain idempotency](./issues/12-c2-on-chain-idempotency.md) | 0019 §1 |
|
||||||
|
| [13 C3/C4 consensus-gated settlement](./issues/13-c3-c4-consensus-gated-settlement.md) | 0019 §2 |
|
||||||
|
| [14 A3 durable Raft term/vote](./issues/14-a3-raft-durable-term-vote.md) | 0019 §3 |
|
||||||
|
| [15 H1 commutative forfeit](./issues/15-h1-commutative-forfeit.md) | 0019 §4 |
|
||||||
|
|
||||||
|
### Phase 4 — Doc hygiene (parallel anytime)
|
||||||
|
|
||||||
|
| Issue |
|
||||||
|
|---|
|
||||||
|
| [16 US-006 + fraud issue reconciliation](./issues/16-doc-us006-reconciliation.md) |
|
||||||
|
| [17 Duplicate US-020 dedup](./issues/17-doc-duplicate-us020-dedup.md) |
|
||||||
|
| [18 Operational runbooks](./issues/18-doc-operational-runbooks.md) |
|
||||||
|
| [19 Cryptography + test env](./issues/19-doc-cryptography-test-env.md) |
|
||||||
|
| [22 MEMORY + project-status index](./issues/22-doc-memory-project-status.md) (done) |
|
||||||
|
| [21 Honest-noise calibration corpus](./issues/21-honest-noise-calibration-corpus.md) (ops; prod gate for audits) |
|
||||||
|
|
||||||
|
## First 3 to implement
|
||||||
|
|
||||||
|
1. **02 + 20** — Unified auth boundary + validator service token (shared helper and roles)
|
||||||
|
2. **01** — Apply hive auth to billing/accounts/stats gossip endpoints
|
||||||
|
3. **05** — Persist strike/ban/reputation (penalties must survive restart)
|
||||||
|
|
||||||
|
## Research anchor
|
||||||
|
|
||||||
|
Fraud design cites [.scratch/alpha-hardening/research-verifiable-inference.md](./research-verifiable-inference.md):
|
||||||
|
|
||||||
|
- **ADOPT** TOPLOC (§8–9, build-vs-adopt table)
|
||||||
|
- **On-demand** commitments, not every request (§8 layer 1 footnote; ADR-0018 §3)
|
||||||
|
- **5% audit budget** as target, not cap (§1.1, §6)
|
||||||
|
- **19× deterrence** via full pending forfeiture (§1.1)
|
||||||
|
- **Hop bisection** blame pattern (§1.2, §8 layer 3)
|
||||||
|
- **Honest-noise corpus** before prod thresholds (§8 layer 3; issue 21)
|
||||||
|
- **Roadmap-only:** zkML, TEE, Gensyn RepOps (§9)
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
<!-- Append triage / implementation notes below -->
|
||||||
111
.scratch/alpha-hardening/handoff.md
Normal file
111
.scratch/alpha-hardening/handoff.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# neuron-tai — Alpha Hardening Handoff
|
||||||
|
|
||||||
|
**Date:** 2026-07-04
|
||||||
|
**Repo:** `D:/DEV/git.d-popov.com/neuron-tai`
|
||||||
|
**Prior session:** Pre-release alpha audit + grilling (design locked; planning artifacts complete)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mission / where we are
|
||||||
|
|
||||||
|
neuron-tai is a volunteer-GPU, pipeline-parallel LLM inference network with a working routing layer and a **broken money/trust path**. Three independent audits agreed: unauthenticated gossip, free-credit faucet, double-pay risks, ephemeral bans, and node self-reported accounting undermine alpha release. The owner locked alpha scope (single settlement tracker, open node join, devnet mock-USDT, carried-forward reputation) and a fraud/verification design (TOPLOC adoption, adaptive audits, on-demand hop bisection, persisted graduated reputation, tracker-authoritative accounting). **Research and planning artifacts are complete** (ADRs 0016–0019, 22 issue files, README index). Next: implement Bucket 1 blockers test-first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Locked decisions
|
||||||
|
|
||||||
|
Point to artifacts — do not re-derive from this handoff.
|
||||||
|
|
||||||
|
| Decision | Status | Reference |
|
||||||
|
|---|---|---|
|
||||||
|
| Alpha scope: one settlement tracker, open join, devnet mock-USDT, reputation carries forward | Locked | `docs/adr/0016-alpha-scope-and-known-limitations.md` |
|
||||||
|
| Two fraud types: correctness (bad output) vs accounting (inflated tokens/shard span) | Locked | Session grilling; research §8 |
|
||||||
|
| Detection: **ADOPT TOPLOC** (MIT, `pip install toploc`); teacher-forced prefill; pin one canonical precision per model | Locked | `.scratch/alpha-hardening/research-verifiable-inference.md` §8 |
|
||||||
|
| Audit rate: **5% default, not a cap**; escalate on anomaly, low reputation, disputes | Locked | Research §1.1, §6, §8 |
|
||||||
|
| Blame: **on-demand** per-hop activation commitments + bisection to first divergent hop (Verde/Truebit **pattern only**) | Locked | Research §1.2; ADR-0018 §3–4 |
|
||||||
|
| Reputation: graduated multiplier (×0.8-per-strike shape), persisted, affects routing + audit rate | Locked | ADR-0018 §6; `packages/validator/README.md` |
|
||||||
|
| Penalty: **full pending forfeiture** is primary hammer (19× deterrence at 5% audit); ×0.8 is routing/payout decay | Locked | Research §1.1; ADR-0018 §1 |
|
||||||
|
| Accounting: tracker authoritative — count tokens from proxied stream; work units from **tracker-assigned** shard span | Locked | ADR-0018 §5; issue 04 |
|
||||||
|
| Persistence prerequisite: strike/ban/reputation/probation must survive tracker restart | Locked | Issue 05 |
|
||||||
|
| Validator service token for forfeit | Locked | ADR-0017 §4; issue 20 |
|
||||||
|
| Honest-noise corpus before prod audit thresholds | Locked | ADR-0018 consequences; issue 21 |
|
||||||
|
| Build vs adopt: TOPLOC **ADOPT**; Verde bisection **ADAPT**; zkML/TEE **roadmap-only** | Locked | Research §9 |
|
||||||
|
| Multi-tracker money-path (C2/C3/C4/A3/H1): **design now, implement later** | Locked | `docs/adr/0019-money-path-consistency-multi-tracker.md` |
|
||||||
|
| Routing layer | **Solid** — no redesign needed | ADR-0013 |
|
||||||
|
|
||||||
|
**Existing ADRs still relevant:** ADR-0003 (historical prototype), ADR-0015 (USDT custodial settlement).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Artifact index (read first)
|
||||||
|
|
||||||
|
| Path | What it contains |
|
||||||
|
|---|---|
|
||||||
|
| `.scratch/alpha-hardening/README.md` | Issue/ADR index + implementation order |
|
||||||
|
| `.scratch/alpha-hardening/issues/` | 22 work items (Buckets 1–3) |
|
||||||
|
| `.scratch/alpha-hardening/research-verifiable-inference.md` | SOTA research, layered alpha scheme (§8), build-vs-adopt (§9) |
|
||||||
|
| `docs/adr/0016–0019` | Alpha scope, auth, fraud, multi-tracker design |
|
||||||
|
| `docs/agents/issue-tracker.md` | Issue file conventions |
|
||||||
|
| `packages/validator/meshnet_validator/__init__.py` | Current validator; `_final_text_node` blame bug |
|
||||||
|
| `packages/tracker/meshnet_tracker/server.py` | Auth gaps, gossip handlers, proxy accounting |
|
||||||
|
| `.claude/memory/MEMORY.md` | Agent memory index |
|
||||||
|
| Agent transcript (grilling session) | [Alpha audit grilling](4406ccbb-011a-4157-851d-b5b242bba7f7) |
|
||||||
|
|
||||||
|
### Bucket summaries
|
||||||
|
|
||||||
|
**Bucket 1 — Alpha blockers:** C1 gossip auth; A2 unified auth + issue 20 validator token; C5 starting credit; H2 tracker accounting; A1/A5 persistence; C6 wallet binding; fraud arc 06–10.
|
||||||
|
|
||||||
|
**Bucket 2 — Design deferred:** C2, C3/C4, A3, H1 (issues 12–15).
|
||||||
|
|
||||||
|
**Bucket 3 — Doc hygiene:** US-006/07/34 reconciliation (16), MEMORY index (22), runbooks (18), cryptography test env (19).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recommended implementation order
|
||||||
|
|
||||||
|
See `.scratch/alpha-hardening/README.md` for full table.
|
||||||
|
|
||||||
|
**First 3:**
|
||||||
|
|
||||||
|
1. **02 + 20** — Unified auth boundary + validator service token
|
||||||
|
2. **01** — Apply hive auth to billing/accounts/stats gossip endpoints
|
||||||
|
3. **05** — Persist strike/ban/reputation
|
||||||
|
|
||||||
|
**Prod gate:** issue **21** (honest-noise corpus) before enabling production audit thresholds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Open questions / deferred
|
||||||
|
|
||||||
|
| Topic | State |
|
||||||
|
|---|---|
|
||||||
|
| Multi-tracker consensus & settlement idempotency | Bucket 2 (ADR-0019) |
|
||||||
|
| `/v1/gossip` node throughput auth | Out of scope alpha — ADR-0017 §3 note |
|
||||||
|
| Seed-synchronized exact-token audits | Optional complement; depends on sampler control |
|
||||||
|
| ADR-0010 TLS everywhere | Relay TLS only in alpha; ADR amended |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Environment notes
|
||||||
|
|
||||||
|
| Item | Detail |
|
||||||
|
|---|---|
|
||||||
|
| OS | Windows 10 |
|
||||||
|
| Repo path | `D:/DEV/git.d-popov.com/neuron-tai` |
|
||||||
|
| Python venv | `.venv/Scripts/python.exe` |
|
||||||
|
| CUDA node | RTX 4060 8 GB; tracker registration requires `https://` |
|
||||||
|
| Secrets | Do not commit `.env.devnet`, keypairs, treasury material |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Suggested skills
|
||||||
|
|
||||||
|
| Skill | When |
|
||||||
|
|---|---|
|
||||||
|
| **implement** | Bucket 1 code work |
|
||||||
|
| **tdd** | All Bucket 1 fixes |
|
||||||
|
| **diagnosing-bugs** | Auth/gossip/accounting failures |
|
||||||
|
| **domain-modeling** | ADR cross-links |
|
||||||
|
| **code-review** | After each blocker milestone |
|
||||||
|
|
||||||
|
Read `.claude/memory/MEMORY.md` at session start.
|
||||||
41
.scratch/alpha-hardening/issues/01-c1-gossip-auth.md
Normal file
41
.scratch/alpha-hardening/issues/01-c1-gossip-auth.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 01 — C1: Authenticate hive gossip endpoints
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Add authenticated peer identity to all tracker gossip mutation endpoints. Today any caller can push billing, account, and stats events without verification.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_handle_billing_gossip` (~2414–2427)
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_handle_accounts_gossip` (~2610–2623)
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_handle_stats_gossip` (~2355–2364)
|
||||||
|
- `packages/tracker/meshnet_tracker/billing.py` — `apply_events` (~301–311)
|
||||||
|
- `packages/tracker/meshnet_tracker/accounts.py` — `apply_events` (~220–226)
|
||||||
|
|
||||||
|
Implement per ADR-0017 §3 using the auth helper/config from issue 02: shared hive HMAC (body + timestamp) or mutual TLS between configured tracker peers. Reject unauthenticated gossip with 401.
|
||||||
|
|
||||||
|
**Note:** `/v1/gossip` (node throughput fan-out, `server.py` ~1331) is **not** in scope for this issue — see ADR-0017 §3 out-of-scope note.
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: unauthenticated POST to `/v1/billing/gossip` applies a credit event today — test must fail after fix.
|
||||||
|
2. Red: authenticated peer with valid HMAC applies events; invalid/missing auth returns 401 and `applied: 0`.
|
||||||
|
3. Green: wire the issue-02 verifier/config (`--hive-secret` or peer cert paths) into the three hive mutation endpoints.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `/v1/billing/gossip`, `/v1/accounts/gossip`, `/v1/stats/gossip` reject requests without valid hive auth
|
||||||
|
- [ ] Authenticated peers replicate events as today (id-dedup preserved)
|
||||||
|
- [ ] Config documented for multi-tracker dev setups
|
||||||
|
- [ ] Tests cover reject + accept paths without live network
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0017](../../docs/adr/0017-tracker-authentication-and-authorization.md)
|
||||||
|
- [ADR-0016](../../docs/adr/0016-alpha-scope-and-known-limitations.md)
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `02-a2-unified-auth-boundary.md` — owns shared auth middleware/config. Implement in the same PR if simpler.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 02 — A2: Unified auth boundary for privileged and financial reads
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Replace header-presence stubs with a single auth middleware that resolves API keys, admin sessions, validator service tokens, and hive peer identity. Close leaks on financial and operator endpoints. This is the auth foundation issue; issue 01 should only apply hive auth to gossip endpoints once the helper exists.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_handle_billing_forfeit` (~2429–2464) — H3: non-empty `Authorization` only
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_handle_benchmark_hop_penalty` (~2650–2658), `_handle_benchmark_results` (~2745–2748) — H3
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_handle_billing_summary` (~2366–2371) — H4
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_handle_billing_settlements` (~2407–2412) — H4
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_handle_registry_wallets` (~2391–2405) — H4
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_session_account` (~2468+), `_handle_admin_accounts` (~2588–2608) — H4
|
||||||
|
- `packages/tracker/meshnet_tracker/accounts.py` — `session_account()`, `create_session()` only (session store; not handler wiring)
|
||||||
|
|
||||||
|
Per ADR-0017 §4: forfeit → validator or admin; benchmark → admin; billing summary/settlements/registry wallets → admin session. Include the validator service token shape from `20-validator-service-token.md` in the same implementation if practical.
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: POST `/v1/billing/forfeit` with `Authorization: Bearer garbage` succeeds today — must require validator/admin identity.
|
||||||
|
2. Red: GET `/v1/billing/summary` without admin session returns 401/403.
|
||||||
|
3. Green: middleware + role checks; existing inference API-key path unchanged.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Single `_require_auth(role=...)` (or equivalent) used by all privileged handlers
|
||||||
|
- [ ] Shared auth config supports admin sessions, validator service token, and hive peer HMAC/mTLS
|
||||||
|
- [ ] Forfeit accepts only validator service token or admin session — not arbitrary Bearer strings
|
||||||
|
- [ ] Financial read endpoints require admin session (alpha posture)
|
||||||
|
- [ ] Benchmark write/read require admin or service token
|
||||||
|
- [ ] Integration tests for each endpoint class (reject unauth, accept valid)
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0017](../../docs/adr/0017-tracker-authentication-and-authorization.md)
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `20-validator-service-token.md` — checklist for validator service token format, rotation, forfeit auth
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
None. This issue should land before `01-c1-gossip-auth.md`.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 03 — C5 + M1: Starting credit 0, funded-account gate, spend cap
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Close the free-credit faucet. New API keys start at **0 USDT**; inference requires a real deposit or admin credit. Add a configurable per-request spend cap (M1) to limit runaway charges on compromised keys.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/tracker/meshnet_tracker/billing.py` — `DEFAULT_STARTING_CREDIT = 1.0` (~22), `ensure_client` (~73–85), `has_funds` (~87–88), duplicate credit on charge (~130–138)
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — billing gate before routing (~1667–1690)
|
||||||
|
|
||||||
|
Per ADR-0017 §2 and ADR-0016 §3.
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: new API key gets 1.0 USDT implicit credit — test expects 0 balance until deposit.
|
||||||
|
2. Red: first inference without deposit returns 402.
|
||||||
|
3. Green: `DEFAULT_STARTING_CREDIT = 0.0`; optional `--max-charge-per-request` config.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `DEFAULT_STARTING_CREDIT` is 0.0; no automatic caller credit on first touch
|
||||||
|
- [ ] `has_funds` false for fresh keys; 402 before routing (server.py ~1684)
|
||||||
|
- [ ] Admin `credit_client` or bound-wallet deposit still funds accounts
|
||||||
|
- [ ] Configurable max charge per request (M1) rejects oversize completions with clear error
|
||||||
|
- [ ] Tests: fresh key blocked; after credit/deposit, inference proceeds
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0017](../../docs/adr/0017-tracker-authentication-and-authorization.md)
|
||||||
|
- [ADR-0016](../../docs/adr/0016-alpha-scope-and-known-limitations.md)
|
||||||
|
- [ADR-0015](../../docs/adr/0015-usdt-custodial-settlement.md)
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `02-a2-unified-auth-boundary.md` (admin credit path secured)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 04 — H2: Tracker-authoritative token and work-unit accounting
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Stop trusting node-reported usage for billing. The tracker already proxies responses — use tracker-observed response data and request limits to cap billable tokens, and compute work units from the **route it constructed**, not node declarations.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `node_work` from route construction (~1776–1782, ~1781–1782)
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — streaming token/chunk billing (~1890–1921)
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — non-streaming `_usage_total_tokens` (~1938–1943)
|
||||||
|
- `packages/tracker/meshnet_tracker/billing.py` — `charge_request` node_work split (~104–151)
|
||||||
|
|
||||||
|
Accounting fraud = inflating tokens or shard span. Per ADR-0018 §5.
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: mock upstream returns inflated `usage.total_tokens` in body but tracker bills that value — test expects the tracker to cap billable tokens from observed stream chunks or request bounds.
|
||||||
|
2. Red: node registers false `shard_end`; billing uses tracker route span, not registration field alone.
|
||||||
|
3. Green: authoritative counters; ignore node-reported work units on charge path.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Streaming token count uses tracker-observed chunks/tokens; upstream `usage.total_tokens` can only lower or match that observed count, never inflate it
|
||||||
|
- [ ] Non-streaming token count caps upstream `usage.total_tokens` by tracker-known request bounds (`max_tokens`, and prompt estimate if available); exact tokenizer-backed counts are deferred unless already available locally
|
||||||
|
- [ ] Work units = tracker-computed layer span per hop at route build time (~1781–1782)
|
||||||
|
- [ ] Nodes cannot increase payout by lying about shard range mid-request
|
||||||
|
- [ ] Integration test: malicious node metadata does not inflate `charge_request` shares
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0018](../../docs/adr/0018-fraud-detection-verification-and-reputation.md) §5
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `02-a2-unified-auth-boundary.md`
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 05 — A1/A5: Persist strike, ban, and reputation state
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Registry strike/ban/reputation state today lives in RAM-only `_LocalContractState` — tracker restart wipes penalties. Persist to SQLite (same pattern as `BillingLedger` and `AccountStore`) so reputation carries forward per ADR-0016 §4.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/contracts/meshnet_contracts/__init__.py` — `RegistryContract`, `RegistryWallet`, in-memory `_state.registry` (~103–206)
|
||||||
|
- `packages/tracker/meshnet_tracker/billing.py` — SQLite persistence pattern (~60, event log)
|
||||||
|
- `packages/tracker/meshnet_tracker/accounts.py` — SQLite + event replication (~40–56)
|
||||||
|
|
||||||
|
Include fields for: `strike_count`, `banned`, `completed_job_count`, graduated **reputation score** (float, default 1.0), `last_audit_ts`, probation tracking.
|
||||||
|
|
||||||
|
**Scope split:** this issue owns **schema + persistence + load/reload** only. Reputation **scoring deltas** (audit pass/fail adjustments, decay rules) belong in issue 08.
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: record strike, restart tracker process, strike count is 0 — must fail.
|
||||||
|
2. Green: persist + reload; gossip replicates strike events if multi-tracker.
|
||||||
|
3. Red: banned wallet registers node — must reject (wire to routing).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Strike/ban/reputation survive tracker restart (SQLite or equivalent)
|
||||||
|
- [ ] `RegistryContract.list_wallets` reflects persisted state
|
||||||
|
- [ ] Banned wallet rejected at registration and excluded from routes
|
||||||
|
- [ ] Reputation score field present for routing/audit issues (08–09)
|
||||||
|
- [ ] Event-sourced mutations compatible with future Raft (ADR-0019)
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0016](../../docs/adr/0016-alpha-scope-and-known-limitations.md) §4
|
||||||
|
- [ADR-0018](../../docs/adr/0018-fraud-detection-verification-and-reputation.md) §6
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `02-a2-unified-auth-boundary.md`
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 06 — FRAUD: TOPLOC integration (teacher-forced audit primitive)
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Adopt [TOPLOC](https://github.com/PrimeIntellect-ai/toploc) (MIT, `pip install toploc`) for activation fingerprint commit and verify. Replace string-equality validator checks with teacher-forced prefill + TOPLOC tolerance matching.
|
||||||
|
|
||||||
|
**Estimated effort:** 2+ sessions. First landing should be the validator-only TOPLOC primitive and docs; node runtime commitments/on-demand capture can follow in issue 07 if this grows.
|
||||||
|
|
||||||
|
| Subtask | Owner package | Deliverable |
|
||||||
|
|---|---|---|
|
||||||
|
| Validator audit primitive | `packages/validator/` | Teacher-forced prefill, TOPLOC verify, unit tests with stub tensors |
|
||||||
|
| Node runtime commitments | `packages/node/` (if prover-side) | On-demand activation fingerprint generation on audit-selected requests; move to issue 07 if it blocks the validator primitive |
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/validator/meshnet_validator/__init__.py` — `_run_reference`, `_outputs_match` (~92–148)
|
||||||
|
- `packages/validator/README.md` — deterrence math (update for 19× at p=0.05)
|
||||||
|
- Research: `.scratch/alpha-hardening/research-verifiable-inference.md` §8 layers 1–2, build-vs-adopt table
|
||||||
|
|
||||||
|
Pin one canonical precision/quantization per model preset. Add `toploc` to validator (and node if prover-side) dependencies.
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: validator compares final text strings — fails on cross-GPU honest divergence (document expected).
|
||||||
|
2. Green: stub activation tensors + TOPLOC proofs round-trip in unit test.
|
||||||
|
3. Integration: reference node teacher-forces tokens; verify accepts honest proof, rejects swapped precision.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `toploc` dependency declared; `build_proofs_*` / `verify_proofs_*` wired
|
||||||
|
- [x] Validator re-runs claimed token sequence as prefill, not free generation
|
||||||
|
- [x] Model preset documents canonical dtype/quantization
|
||||||
|
- [x] README updated: 19× deterrence at 5% audit (research §1.1)
|
||||||
|
- [x] Tests with deterministic stub tensors (no GPU required in CI)
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0018](../../docs/adr/0018-fraud-detection-verification-and-reputation.md) §2
|
||||||
|
- Research: [research-verifiable-inference.md](../research-verifiable-inference.md) §8, §9 build-vs-adopt
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `05-a1-a5-persist-strike-ban-reputation.md`
|
||||||
|
|
||||||
|
**Prod gate:** do not enable production audit thresholds until `21-honest-noise-calibration-corpus.md` completes (see README Phase 2 note).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 07 — FRAUD: On-demand commitment + hop bisection blame
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
On audit selection, require nodes to supply TOPLOC-style fingerprints of **output boundary activations** per hop (on-demand, brief retention). On verify failure, referee identifies the **first divergent hop** — not always the last text node.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/validator/meshnet_validator/__init__.py` — `_slash_route`, `_final_text_node` bug (~102–140) — blames `max(shard_end)` only
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — route hop construction (~1774–1783) — cut-points for bisection
|
||||||
|
- Research: `.scratch/alpha-hardening/research-verifiable-inference.md` §1.2, §8 layer 3 (Verde **pattern**, not on-chain game)
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: two-hop route, corrupt hop-0 activations — `_final_text_node` blames hop-1 — test must fail.
|
||||||
|
2. Green: bisection selects hop-0; forfeit targets hop-0 wallet.
|
||||||
|
3. On-demand: commitment requested only when audit flag set on proxied request.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Audit requests carry tracker RNG/VRF flag indistinguishable from normal traffic (research §6) — the existing post-hoc `sample_rate` RNG gate in `ValidatorProcess.validate_once` already decides audit selection after the original proxied request completed, so the request the client/nodes saw is unaffected either way; locked in by `test_hop_commitments_are_not_requested_unless_the_event_is_audit_selected`
|
||||||
|
- [x] Nodes retain recent boundary activations for on-demand commit window (configurable TTL) — `ToplocAuditConfig.commitment_ttl_seconds`; expired commitments fall back to the text-only path (`test_expired_commitment_window_falls_back_to_text_only_audit`)
|
||||||
|
- [x] Validator/tracker compares fingerprints at each hop cut-point; first mismatch = culprit — `_hop_commitments_from_event` + `_first_divergent_hop` in `packages/validator/meshnet_validator/__init__.py`
|
||||||
|
- [x] `_final_text_node` removed or limited to text-only fallback — only called from the plain-text divergence branch of `_validate_event` now
|
||||||
|
- [x] Integration test: multi-hop pipeline, fault injected at known hop — `tests/test_hop_bisection.py`
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0018](../../docs/adr/0018-fraud-detection-verification-and-reputation.md) §3–4
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `06-fraud-toploc-integration.md`
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 08 — FRAUD: Reputation model + persistence
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Implement graduated reputation per ADR-0018 §6: score derives only from tracker audit outcomes + uptime/latency. Slow build, instant loss, inactivity decay. ×0.8 routing multiplier per strike (not whole penalty — forfeiture stays full pending).
|
||||||
|
|
||||||
|
**Scope split:** issue 05 owns **schema + SQLite persistence**; this issue owns **scoring rules** (deltas, decay, strike→multiplier wiring) on top of persisted fields.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/contracts/meshnet_contracts/__init__.py` — extend `RegistryWallet` / persistence from issue 05
|
||||||
|
- `packages/validator/meshnet_validator/__init__.py` — `_slash_route` forfeiture path (~125–133)
|
||||||
|
- `packages/tracker/meshnet_tracker/billing.py` — `forfeit_pending` (~280–292)
|
||||||
|
- Research: `.scratch/alpha-hardening/research-verifiable-inference.md` §6
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: persisted reputation/strike fields from issue 05 are ignored by scoring/routing today.
|
||||||
|
2. Green: clean audit +0.05 (tunable); failed audit −0.3 and strike; three strikes → ban persisted via issue-05 fields.
|
||||||
|
3. Inactivity decay after N days without completed jobs.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Uses `reputation_score` and strike/ban fields persisted by issue 05; does not introduce a second schema path
|
||||||
|
- [ ] Audit pass/fail updates score with documented deltas
|
||||||
|
- [ ] Strike applies ×0.8 multiplier to routing weight (separate from forfeiture amount)
|
||||||
|
- [ ] Ban at 3 strikes; probation job count still enforced
|
||||||
|
- [ ] No peer-to-peer reputation inputs
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0018](../../docs/adr/0018-fraud-detection-verification-and-reputation.md) §6
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `05-a1-a5-persist-strike-ban-reputation.md`
|
||||||
|
- `07-fraud-commitment-bisection-blame.md` (audit outcomes feed reputation)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 09 — FRAUD: Reputation-weighted routing + adaptive audit rate
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Wire reputation into route selection and audit sampling. Default network audit budget ≈5% — **not a cap**. New/low-reputation nodes: 20–30% audit rate; veterans: 2–3% floor ≥2%. Tripwires escalate rate without direct punishment.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — route selection `_select_route`, `_effective_throughput` (~1747, routing helpers)
|
||||||
|
- `packages/validator/meshnet_validator/__init__.py` — `sample_rate=0.05`
|
||||||
|
- Research: `.scratch/alpha-hardening/research-verifiable-inference.md` §1.1, §6, §8 layers 2–4
|
||||||
|
|
||||||
|
Audit selection must be unpredictable at request time (tracker RNG after commitment window opens).
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: uniform 5% sample regardless of reputation — test expects higher rate for low-reputation wallet.
|
||||||
|
2. Green: budget balancer keeps fleet-wide average ≈ configured target.
|
||||||
|
3. Routing prefers higher reputation among equal throughput candidates.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Per-wallet audit probability function of reputation (newcomer high, veteran low, floor ≥2%)
|
||||||
|
- [ ] Fleet-wide audit budget configurable (~5% default target); over ≥1000 requests with fixed seed, measured fleet audit rate within **±1.0 percentage point** of configured target (e.g. 4.0–6.0% at 5% default)
|
||||||
|
- [ ] Route scoring includes reputation multiplier (earnings scale with tenure)
|
||||||
|
- [ ] Passive tripwire flags (perplexity/repetition) bump audit rate only
|
||||||
|
- [ ] Tests: deterministic seed for sampling distribution checks
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0018](../../docs/adr/0018-fraud-detection-verification-and-reputation.md) §1, §6–7
|
||||||
|
- [ADR-0013](../../docs/adr/0013-rolling-stats-smart-routing.md)
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `08-fraud-reputation-model-persistence.md`
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 10 — FRAUD: Penalty calibration wiring (forfeit + strike + ban)
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
End-to-end wiring: confirmed audit failure → atomic pending forfeiture + strike + reputation decay + audit-rate snap to max. Ensure payout cannot race penalty (ADR-0015). Document 19× deterrence math in validator README.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/validator/meshnet_validator/__init__.py` — `_slash_route` (~102–134)
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_handle_billing_forfeit` (~2429–2464)
|
||||||
|
- `packages/tracker/meshnet_tracker/billing.py` — `forfeit_pending` (~280–292), payout exclusion for banned (~3337–3344 in settlement loop)
|
||||||
|
- `packages/validator/README.md` — update 20× → 19× at p=0.05
|
||||||
|
|
||||||
|
Per ADR-0018: **full pending forfeiture** is primary penalty; ×0.8 is routing decay per strike, not partial forfeit.
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: integration from issue 34 — extend with multi-hop blame wallet from issue 07.
|
||||||
|
2. Green: node with pending balance → audit fail → pending zero, strike++, banned on 3rd, excluded from next settlement.
|
||||||
|
3. Settlement loop skips banned wallets (~3337–3344).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Audit failure triggers forfeiture + strike in one tracker transaction — `ValidatorProcess._slash_node` (in-process) and the tracker's `_handle_billing_forfeit` handler (remote) both forfeit-then-strike synchronously in a single call path; each already existed pre-AH-010 and is exercised by `tests/test_forfeiture_penalty.py`
|
||||||
|
- [x] Banned nodes excluded from `payables` / settlement — `BillingLedger.settle_node_payout` now clamps to the wallet's *current* pending balance under the same lock as the debit, and `_settlement_loop` rechecks ban status and uses the post-clamp amount before sending, so a forfeiture landing between the `payables()` snapshot and the actual payout can never be paid out on top of (ADR-0015 race); covered by `test_60_request_stream_bans_intermittent_first_hop_cheater_not_last_hop`
|
||||||
|
- [x] Validator uses authenticated forfeit endpoint (issue 02) — `POST /v1/billing/forfeit` is validator-token/admin-gated (ADR-0017 §4, issue 20) and is the documented remote path (`packages/validator/README.md` Usage section); `test_forfeit_endpoint_requires_auth_and_forfeits` exercises the 401→200 flow. No standalone remote-validator process exists in this codebase yet (`contracts` has no networked implementation), so the in-process `ValidatorProcess` continues to call `BillingLedger.forfeit_pending` directly when co-located with the tracker — adding an HTTP-only forfeit client with no real consumer was judged out of scope/overengineering for this issue
|
||||||
|
- [x] README: `L > 19× g` at p=0.05; pending balance = collateral — already present in `packages/validator/README.md` ("Why the penalty deters cheating")
|
||||||
|
- [x] Integration test: 60-request fraud scenario → ban within threshold — `tests/test_forfeiture_penalty.py::test_60_request_stream_bans_intermittent_first_hop_cheater_not_last_hop`
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0018](../../docs/adr/0018-fraud-detection-verification-and-reputation.md)
|
||||||
|
- [ADR-0015](../../docs/adr/0015-usdt-custodial-settlement.md)
|
||||||
|
- Research: [research-verifiable-inference.md](../research-verifiable-inference.md) §1.1
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `07-fraud-commitment-bisection-blame.md`
|
||||||
|
- `08-fraud-reputation-model-persistence.md`
|
||||||
|
- `02-a2-unified-auth-boundary.md`
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 11 — C6: Wallet binding ownership proof + binding overwrite safety
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
`POST /v1/wallet/register` binds a client Solana wallet to an API key for deposit attribution. Today any Bearer key can bind any wallet string without proving ownership. Prevent hijack and accidental overwrite.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_handle_wallet_register` (~2625–2648)
|
||||||
|
- `packages/tracker/meshnet_tracker/billing.py` — `bind_wallet` (~153+), `_wallet_bindings` / direct overwrite on apply (~351)
|
||||||
|
|
||||||
|
Require signed message from wallet pubkey (ed25519 via `cryptography` / solders). Reject rebinding without admin or signed release. Use explicit overwrite policy — today `~351` overwrites binding directly; gossip apply must reject conflicting binds instead of silently clobbering.
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: bind wallet A with only API key, no signature — must fail after fix.
|
||||||
|
2. Red: wallet already bound to key1; key2 cannot steal without proof.
|
||||||
|
3. Green: valid signature binds; deposit watcher credits correct API key.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Wallet binding requires cryptographic proof of pubkey ownership
|
||||||
|
- [x] One wallet → one API key (or documented admin override)
|
||||||
|
- [x] Gossip `bind` events cannot overwrite existing binding via direct overwrite at `~351`
|
||||||
|
- [x] Tests with deterministic keypairs (local adapter)
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0017](../../docs/adr/0017-tracker-authentication-and-authorization.md) §5
|
||||||
|
- [ADR-0015](../../docs/adr/0015-usdt-custodial-settlement.md)
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `02-a2-unified-auth-boundary.md`
|
||||||
|
- `03-c5-starting-credit-zero.md`
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
Status: ready-for-human
|
||||||
|
|
||||||
|
# 12 — C2: On-chain settlement idempotency (deferred)
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Harden payout idempotency so Solana transaction retries never double-pay. Design accepted in ADR-0019 §1; **implementation deferred post-alpha**.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — `_settlement_loop` resend (~3331–3356), `_send_settlement` (~3358–3376)
|
||||||
|
- `packages/contracts/meshnet_contracts/solana_adapter.py` — `send_payouts` (~186–213)
|
||||||
|
|
||||||
|
Today: pending debited before broadcast with stable `settlement_id`; unconfirmed batches resent. Gap: on-chain confirmation vs ledger state if tx succeeds but confirm fails.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `confirm_settlement` only after RPC finalized confirmation
|
||||||
|
- [ ] Retry path reuses same `settlement_id` and detects already-confirmed signature
|
||||||
|
- [ ] Property test: N retries → single on-chain transfer per wallet per settlement_id
|
||||||
|
- [ ] Document recovery procedure for stuck unconfirmed batches
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0019](../../docs/adr/0019-money-path-consistency-multi-tracker.md) §1
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
Alpha release (ADR-0016 single settlement tracker)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
Status: ready-for-human
|
||||||
|
|
||||||
|
# 13 — C3/C4: Consensus-gated money mutations (deferred)
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Route money-affecting ledger events through Raft commit, not gossip-only apply. Extend `raft.py` command set beyond register/deregister. Settlement remains leader-only with treasury key.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/tracker/meshnet_tracker/server.py` — settlement leader gate (~3331–3332), payout batch (~3353–3356)
|
||||||
|
- `packages/tracker/meshnet_tracker/raft.py` — log entry types (~26–27)
|
||||||
|
- `packages/tracker/meshnet_tracker/billing.py` — `apply_events` (~301–311)
|
||||||
|
|
||||||
|
Design: ADR-0019 §2. **Deferred post-alpha** while single operator holds settlement.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `charge`, `payout`, `forfeit`, `credit`, `settlement`, `bind` commit via Raft log
|
||||||
|
- [ ] Followers reject direct gossip money mutations
|
||||||
|
- [ ] Leader-only `_settlement_loop` unchanged in semantics
|
||||||
|
- [ ] Migration plan from gossip-only billing to Raft-backed log
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0019](../../docs/adr/0019-money-path-consistency-multi-tracker.md) §2
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `12-c2-on-chain-idempotency.md`
|
||||||
|
- `14-a3-raft-durable-term-vote.md`
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
Status: ready-for-human
|
||||||
|
|
||||||
|
# 14 — A3: Durable Raft term and vote state (deferred)
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Persist Raft `currentTerm`, `votedFor`, and log metadata to disk. In-memory-only term (~26) risks split leadership after tracker restart → duplicate settlement epochs.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/tracker/meshnet_tracker/raft.py` — `LogEntry.term` (~25–27), election state in `RaftNode`
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Term/vote persisted alongside tracker data dir
|
||||||
|
- [ ] Restart resumes as follower/candidate with monotonic term
|
||||||
|
- [ ] Test: kill leader mid-settlement, restart, no duplicate payout batch
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0019](../../docs/adr/0019-money-path-consistency-multi-tracker.md) §3
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
Alpha single-settlement posture
|
||||||
27
.scratch/alpha-hardening/issues/15-h1-commutative-forfeit.md
Normal file
27
.scratch/alpha-hardening/issues/15-h1-commutative-forfeit.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
Status: ready-for-human
|
||||||
|
|
||||||
|
# 15 — H1: Commutative forfeit event ordering (deferred)
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Define deterministic ordering when `forfeit`, `charge`, and `payout` events replicate concurrently. Forfeit snapshots amount at creation (~287) but apply order can desync pending balances under gossip.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/tracker/meshnet_tracker/billing.py` — `forfeit_pending` (~280–292), `_apply_locked` forfeit branch (~345–349)
|
||||||
|
- `packages/tracker/meshnet_tracker/billing.py` — `_pending_since.setdefault` (~324), wallet bind direct overwrite (~351)
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Documented commit order: charges before forfeit before payout for same wallet epoch
|
||||||
|
- [ ] Forfeit events carry pending snapshot or `(term, index)` for tie-break
|
||||||
|
- [ ] `setdefault` replaced with explicit merge rules on out-of-order apply
|
||||||
|
- [ ] Property tests under shuffled event delivery
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0019](../../docs/adr/0019-money-path-consistency-multi-tracker.md) §4
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `13-c3-c4-consensus-gated-settlement.md`
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 16 — DOC: US-006 reconciliation note
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Reconcile stale US-006 (Solana testnet stake contracts) with ADR-0015/0016 devnet custodial settlement. Issue `docs/issues/06-solana-stake-and-settlement.md` says "never devnet"; ADR-0015 explicitly targets devnet mock-USDT.
|
||||||
|
|
||||||
|
Also reconcile legacy fraud issues with the alpha-hardening fraud arc:
|
||||||
|
|
||||||
|
- `docs/issues/07-fraud-detection-slash.md` — on-chain stake slash model superseded by pending-balance forfeiture + TOPLOC (ADR-0018)
|
||||||
|
- `docs/issues/34-forfeiture-penalty.md` — partially implemented; remaining fraud work lives in `.scratch/alpha-hardening/issues/06-fraud-toploc-integration.md` through `10-fraud-penalty-calibration-wiring.md`
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Add reconciliation comment atop `docs/issues/06-solana-stake-and-settlement.md` (Status: superseded for alpha — see ADR-0015, issue 33/34)
|
||||||
|
- [ ] Add **superseded** banner atop `docs/issues/07-fraud-detection-slash.md` → ADR-0018 + issues 06–10
|
||||||
|
- [ ] Add **superseded for remaining scope** banner atop `docs/issues/34-forfeiture-penalty.md` → ADR-0018 + issues 06–10 (note done items: basic forfeiture wired)
|
||||||
|
- [ ] Update `docs/prd.json` US-006 description footnote if present
|
||||||
|
- [ ] Cross-link ADR-0015 devnet decision
|
||||||
|
- [ ] No production code changes
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0015](../../docs/adr/0015-usdt-custodial-settlement.md)
|
||||||
|
- [ADR-0016](../../docs/adr/0016-alpha-scope-and-known-limitations.md)
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
None
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
Status: ready-for-human
|
||||||
|
|
||||||
|
# 17 — DOC: Duplicate US-020 issue dedup
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Two files share the US-020 number with different slugs:
|
||||||
|
|
||||||
|
- `docs/issues/20-memory-budget-shard-slots-and-dropout-relocation.md` (ready-for-agent)
|
||||||
|
- `docs/issues/20-tracker-node-hardening.md` (done)
|
||||||
|
|
||||||
|
Resolve numbering collision without losing history.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Document canonical mapping in this issue's Comments or a short `docs/issues/README.md` note
|
||||||
|
- [ ] Renumber or prefix disambiguation (e.g. keep done item as US-020a, renumber memory-budget to next slot) — **human approval before git mv**
|
||||||
|
- [ ] Update any prd.json / cross-links that reference US-020 ambiguously
|
||||||
|
- [ ] No production code changes
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
Human approval for renumbering. An agent may prepare the mapping note, but must not run `git mv` or rewrite cross-links until the canonical number is approved.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 18 — DOC: Operational runbooks (stubs)
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Add operational runbook stubs for alpha operators under `docs/runbooks/` (or `.scratch/alpha-hardening/runbooks/` until close-feature):
|
||||||
|
|
||||||
|
1. **Ledger backup** — billing SQLite, accounts SQLite, registry DB paths; gossip pause procedure
|
||||||
|
2. **Treasury key rotation** — devnet mock-USDT mint + treasury keypair rotation without double-credit
|
||||||
|
3. **Upgrade path** — tracker rolling restart with persisted strike/reputation (post issue 05)
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Three markdown runbook stubs with prerequisites, steps, rollback
|
||||||
|
- [ ] Reference ADR-0015 settlement loop and ADR-0016 trust assumptions
|
||||||
|
- [ ] Secrets handling: never commit `.env.devnet`, keypairs
|
||||||
|
- [ ] No production code changes
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0015](../../docs/adr/0015-usdt-custodial-settlement.md)
|
||||||
|
- [ADR-0016](../../docs/adr/0016-alpha-scope-and-known-limitations.md)
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
None (stubs can land before issue 05; update after persistence ships)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 19 — DOC: Cryptography dependency + test environment note
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Document and verify test/dev environment setup for wallet crypto paths. `packages/node/meshnet_node/wallet.py` uses `cryptography`; failures occur when `.venv` lacks deps. `cryptography>=41` is already declared in `packages/node/pyproject.toml`, so this issue should focus on documenting the editable-install path and only add root/dev extras if tests still import the node wallet without installing the node package.
|
||||||
|
|
||||||
|
**Code refs:**
|
||||||
|
|
||||||
|
- `packages/node/pyproject.toml` — `cryptography>=41` (verify declared)
|
||||||
|
- `packages/node/meshnet_node/wallet.py`
|
||||||
|
- Handoff: tests fail without `cryptography`, `openai`, `langchain` in `.venv`
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Confirm `cryptography>=41` remains in node package deps; add to root/dev extras only if tests import wallet without node install
|
||||||
|
- [x] Add short **Test environment** section to `docs/dev/test-env.md` (or `CONTRIBUTING.md` if created): use `.venv/Scripts/python.exe`, `pip install -e packages/node ...`, optional dep skips
|
||||||
|
- [x] Note which tests require optional deps (`--ignore=test_openai_gateway,...`)
|
||||||
|
- [x] No unrelated production code changes
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
None
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
- `packages/node/pyproject.toml` already declared `cryptography>=41` — no change needed.
|
||||||
|
- `conftest.py` adds every `packages/*` dir to `sys.path`, so first-party imports (e.g. `meshnet_node.wallet`) resolve without an editable install of that package — but third-party deps like `cryptography` still must be installed separately. Added `cryptography>=41` to the root `pyproject.toml` `dev` extra so `pip install -e ".[dev]"` alone covers the wallet tests (`test_node_startup.py`, `test_wallet_binding_proof.py`, `test_devnet_treasury.py`, etc.) without requiring a full `packages/node` install (which would otherwise pull in torch/transformers/accelerate/bitsandbytes).
|
||||||
|
- Added `docs/dev/test-env.md` with setup instructions (Linux + Windows `.venv\Scripts\python.exe`), and a note on optional-dependency tests: `test_real_model_backend.py` / `test_devnet_treasury.py` use `pytest.importorskip` and skip cleanly; `test_openai_gateway.py` hard-imports `openai`/`langchain_openai` with no skip guard (both already in the `dev` extra) — documented the `--ignore=tests/test_openai_gateway.py` fallback for minimal installs.
|
||||||
|
- Full suite: 311 passed, 3 skipped, 3 pre-existing failures unrelated to this issue (`test_billing_ledger.py::test_proxy_chat_splits_payout_by_tracker_assigned_route_span`, `test_forfeiture_penalty.py::test_probation_earns_nothing_then_earning_begins`, `test_mining_cli.py::test_legacy_start_without_port_uses_next_available_port` — port-in-use env artifact). Wallet-specific tests (`test_wallet_binding_proof.py`, `test_node_startup.py`, `test_devnet_treasury.py`): 50 passed, 2 skipped.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 20 — Validator service token for `/v1/billing/forfeit`
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Define and implement a **validator service token** distinct from client API keys and admin sessions. The validator process must authenticate when calling `POST /v1/billing/forfeit`; arbitrary Bearer strings and client API keys must be rejected. This is a checklist subtask for issue 02 and should normally land in the same PR as the unified auth middleware.
|
||||||
|
|
||||||
|
Per [ADR-0017 §4](../../docs/adr/0017-tracker-authentication-and-authorization.md): forfeit accepts **validator service identity or admin session** only.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Item | Alpha default |
|
||||||
|
|---|---|
|
||||||
|
| Env var | `MESHNET_VALIDATOR_SERVICE_TOKEN` (tracker + validator) |
|
||||||
|
| Config flag | `--validator-service-token` / tracker config file equivalent |
|
||||||
|
| Header format | `Authorization: Bearer <service-token>` with a dedicated prefix or separate header scheme documented in runbooks (e.g. `Authorization: Service <token>` — pick one and test consistently) |
|
||||||
|
| Rotation | Manual: set new token on tracker + validator, restart both; document zero-downtime rotation as post-alpha |
|
||||||
|
|
||||||
|
## Rejection rules
|
||||||
|
|
||||||
|
- Client API keys (`sk-mesh-…`) → **403** on forfeit (even if valid for inference)
|
||||||
|
- Non-empty garbage Bearer → **401/403**
|
||||||
|
- Missing auth → **401**
|
||||||
|
- Valid validator service token → **200** (existing forfeit semantics)
|
||||||
|
- Admin session → **200** (operator override)
|
||||||
|
|
||||||
|
## Test-first
|
||||||
|
|
||||||
|
1. Red: validator (or test client) posts forfeit with a valid API key — must fail after fix.
|
||||||
|
2. Red: `Authorization: Bearer garbage` — must fail (covered by issue 02; this issue defines the accepted token).
|
||||||
|
3. Green: configured service token succeeds; wrong token fails.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Service token configurable via env/flag on tracker and validator
|
||||||
|
- [ ] Unified auth middleware resolves service token → `validator` role (issue 02)
|
||||||
|
- [ ] API keys explicitly rejected on forfeit path
|
||||||
|
- [ ] Integration test: validator client with service token forfeit succeeds; API key forfeit fails
|
||||||
|
- [ ] Runbook stub: rotation procedure (manual alpha)
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0017](../../docs/adr/0017-tracker-authentication-and-authorization.md) §4
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `02-a2-unified-auth-boundary.md` — middleware + role checks
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `02-a2-unified-auth-boundary.md`
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
Status: ready-for-human
|
||||||
|
|
||||||
|
**BLOCKS ALPHA RELEASE.** Scoped 2026-07-06 during alpha-launch-readiness grilling session — must complete before real-money (mainnet SOL/USDT) traffic goes live for the friends + hired-VPS-host launch. Loose/uncalibrated thresholds + manual admin slash-reversal are the stopgap only until this closes.
|
||||||
|
|
||||||
|
**Engineering complete 2026-07-06; blocked on a human running it against the real hired-VPS fleet before launch.** The three code gaps below are closed and unit-tested (see Deliverables), but nothing in a dev session can stand in for actually dispatching the job at real hardware — that step, plus the threshold/FPR write-up that depends on its output, needs an operator with the live fleet. See the validator README's "Honest-noise calibration corpus" section for the operational how-to.
|
||||||
|
|
||||||
|
# 21 — Honest-noise TOPLOC calibration corpus
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Before enabling production TOPLOC audit thresholds, collect an **honest-noise baseline** across the active fleet. Run identical inference jobs on every active node/GPU combo; measure the divergence envelope (TOPLOC exponent/mantissa deltas, logprob-rank spread) under real hardware variance. This must be driven by the tracker (scheduled/dispatched job), not a manual one-off script, so it can be re-run as the fleet's hardware mix changes.
|
||||||
|
|
||||||
|
Per [ADR-0018 consequences](../../docs/adr/0018-fraud-detection-verification-and-reputation.md): threshold calibration requires an honest-noise corpus across the fleet before production thresholds.
|
||||||
|
|
||||||
|
Research anchor: `.scratch/alpha-hardening/research-verifiable-inference.md` §8 layer 3 — "collect this first — run identical jobs across the current node fleet to measure the honest divergence envelope before setting thresholds."
|
||||||
|
|
||||||
|
**Launch context (why this is buildable now, not a research project):** first-launch nodes are hired VPS/VPC hosts under our own direct control (test infrastructure we pay for, not third-party volunteers) — not a long-term topology, but risk-free for calibration purposes since there's no external party to dispute a bad reading. Friends are client-side users of the API in this phase, not node operators. Run the calibration pass against this small, fully-controlled fleet first; hired hosts stay stake-free until it's done, then move to real staking once thresholds derive from their own hardware.
|
||||||
|
|
||||||
|
**Current gap (confirmed 2026-07-06 by code read):** none of the three pieces below exist yet.
|
||||||
|
|
||||||
|
1. `verify_activation_proofs()` (`packages/validator/meshnet_validator/audit.py:94-127`) returns a **plain bool** — no raw TOPLOC divergence/distance value is ever computed or surfaced. Every "done" fraud-detection issue (06–10) currently runs on a guessed threshold baked into that bool, not a calibrated one.
|
||||||
|
2. Fleet dispatch exists but is the wrong shape: `_handle_benchmark_hop_penalty` / `_handle_benchmark_results` (`packages/tracker/meshnet_tracker/server.py:2998-3104`, from the old US-030 latency work) targets pinned 1–3-node *routes* and measures latency, not TOPLOC divergence across *every* registered node.
|
||||||
|
3. Storage is the wrong shape: `record_audit_outcome` (`packages/contracts/meshnet_contracts/__init__.py:416`) persists only `strike_count`/`banned`/`passed` to `registry_events` — no divergence value, no GPU/dtype/hardware-profile column anywhere. Benchmark results otherwise land in a flat JSON file (`server.benchmark_results_path`), not a queryable per-node/hardware schema.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
- [x] Extend the TOPLOC verify call path (`audit.py`) to return the raw distance/divergence metric alongside the existing bool — `verify_activation_proofs_detailed()` / `ToplocVerificationResult` in `packages/validator/meshnet_validator/audit.py`; `verify_activation_proofs()` kept as a thin bool-only wrapper for existing callers. Also fixes a real bug this issue's code-read surfaced: the old code did `bool(_call_toploc(...))`, which is always `True` for the real `toploc` library's non-empty per-chunk `VerificationResult` list regardless of divergence — `tests/test_toploc_audit.py::test_verify_activation_proofs_detailed_aggregates_per_chunk_divergence` exercises this directly.
|
||||||
|
- [x] Extend the existing fleet-dispatch pattern (`server.py:2998+`) from pinned-route benchmarking to a tracker-scheduled job that hits **every currently registered node** with a fixed prompt/model/seed — `POST /v1/calibration/toploc/run` (admin/validator-gated, same shape as `POST /v1/benchmark/hop-penalty`) in `packages/tracker/meshnet_tracker/server.py`. Dispatches to every node that can solo-serve the full model range (single-hop pinned route, isolating one node's hardware noise from route-composition effects); partial-shard nodes are reported under `skipped_partial_shard_node_ids`, and nodes that don't answer the on-demand TOPLOC commitment fetch are reported per-node under `"skipped": "..."` rather than counted as pass or fail. See `tests/test_toploc_calibration_dispatch.py`.
|
||||||
|
- [x] Add a small SQLite table (same pattern as `billing.py`/`accounts.py`) keyed by node wallet + GPU model + dtype, storing the divergence value per calibration run — `packages/tracker/meshnet_tracker/calibration.py::ToplocCalibrationStore`, `toploc_calibration_runs` table.
|
||||||
|
- [x] Aggregation: p99 honest envelope + safety margin computed from that table, written as the recommended tolerance constants — `ToplocCalibrationStore.envelope()`, exposed via `GET /v1/calibration/toploc/results`.
|
||||||
|
- [x] Gate checklist: production audit enable blocked until corpus covers ≥N distinct hardware profiles — `ToplocCalibrationStore.gate_status(min_hardware_profiles=N)`; N is `--toploc-calibration-gate-min-hardware-profiles` (default 1) on the tracker CLI, documented alpha exception in the validator README.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Corpus collected from the current hired-VPS fleet (documented as a small-fleet alpha corpus, not the eventual volunteer-fleet corpus) — **not done: needs a human to run `POST /v1/calibration/toploc/run` against the live hired-VPS fleet before launch; no such fleet exists in a dev session.**
|
||||||
|
- [ ] Threshold constants in validator config derived from corpus, not guessed — mechanically ready (`envelope()` returns them) but depends on the real corpus above; not yet wired into `ToplocAuditConfig` as enforced thresholds (deliberately — enforcing unvalidated thresholds would be worse than today's guessed bool).
|
||||||
|
- [ ] False-positive rate estimate documented at chosen thresholds — `envelope()` returns `estimated_false_positive_rate` (in-sample: fraction of the recorded corpus the recommended thresholds would themselves flag); needs the real corpus to be a meaningful number, and should be written up in the runbook once collected.
|
||||||
|
- [x] README / runbook cross-link: **do not enable production audits** until this issue closes — `packages/validator/README.md` "TOPLOC audit contract" section, updated with the full operational how-to.
|
||||||
|
- [x] Note in the runbook that this alpha corpus must be re-run once the fleet grows beyond the hired-VPS set (different hardware mix invalidates the envelope) — same README section.
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0018](../../docs/adr/0018-fraud-detection-verification-and-reputation.md) — Consequences (honest-noise corpus)
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- `06-fraud-toploc-integration.md` (TOPLOC wired; calibration uses same primitive) — done
|
||||||
|
|
||||||
|
## Blocks (prod gate)
|
||||||
|
|
||||||
|
- Alpha release to real-money friends+hired-VPS launch (raised from "production adaptive audit thresholds" to a hard alpha-release gate during 2026-07-06 grilling)
|
||||||
|
- Production enable of adaptive audit thresholds (issues 09–10 in prod)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
# 22 — DOC: MEMORY.md + project-status alpha-hardening index
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Update persistent memory files so agents and humans find the alpha-hardening feature without stale handoff paths.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `.claude/memory/MEMORY.md` — index entry for alpha-hardening (`.scratch/alpha-hardening/`, ADRs 0016–0019, issue count)
|
||||||
|
- [x] `.claude/memory/project-status.md` — brief alpha-hardening section: planning complete, Bucket 1 blockers next, link README
|
||||||
|
- [x] Cross-link `.scratch/alpha-hardening/handoff.md` from README (not temp path)
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0016](../../docs/adr/0016-alpha-scope-and-known-limitations.md)
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
None — completed
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
2026-07-04 triage: already satisfied by `.claude/memory/MEMORY.md`, `.claude/memory/project-status.md`, and `.scratch/alpha-hardening/README.md`.
|
||||||
53
.scratch/alpha-hardening/issues/23-dynamic-hf-pricing.md
Normal file
53
.scratch/alpha-hardening/issues/23-dynamic-hf-pricing.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
Status: done
|
||||||
|
|
||||||
|
Scoped 2026-07-06 during alpha-launch-readiness grilling session. High priority, ship-soon for launch — **not** an alpha-release blocker (unlike issue 21): a stale/static price is a revenue/business-model risk, not a safety risk, so the friends + hired-VPS launch may proceed on the current static default while this lands in parallel.
|
||||||
|
|
||||||
|
# 23 — Dynamic per-model pricing benchmarked against HuggingFace inference rates
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Client-facing price per model should track the market: **80% of the cheapest comparable provider rate on HuggingFace's inference marketplace** (`https://huggingface.co/inference/models`), refreshed daily, auto-adjusting so served models stay competitively priced as the market moves. Nodes are unaffected by this loop (per launch design: clients are the only party spending real money; node payouts come from the 90/10 split of whatever price is charged, per ADR-0015/`packages/validator/README.md`).
|
||||||
|
|
||||||
|
**Current state (confirmed by code read 2026-07-06):** pricing is 100% static today. `DEFAULT_PRICE_PER_1K_TOKENS = 0.02` (`packages/tracker/meshnet_tracker/billing.py:21`) is the fallback nearly every model hits, since `model_presets.json` currently has no `price_per_1k_tokens` key for any preset. `BillingLedger.set_price(model, price)` (`billing.py:67-69`) is the only write path and already exists — no CLI/admin route calls it yet. No external HTTP/market-data integration exists anywhere in the tracker.
|
||||||
|
|
||||||
|
**Data source:** `https://huggingface.co/inference/models` aggregates multiple providers (novita, together, fireworks-ai, deepinfra, etc.) with per-model, per-provider $/1M input and output token pricing; the "cheapest" badge already identifies the lowest-cost provider per model on the page itself. It supports a GET query param for filtering, e.g. `?search=GLM`. **No confirmed public JSON API was found** during this session's fetch — the page reads as a rendered table. Owner's suggestion: try a plain `requests` + BeautifulSoup scrape first; if the pricing table turns out to be client-rendered (not present in the initial HTML), that's the fallback signal to escalate to a headless-browser fetch (e.g. Playwright) — confirm which is needed during implementation before building the full pipeline around it. Another data source is acceptable if more convenient/stable, owner is not wedded to this specific page.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
- [x] Live-fetch attempt (requests + BeautifulSoup against the HF page with `?search=<model-family>`, or an equivalent stable source) as the primary path — confirm during implementation whether the pricing table is present in the raw HTML or requires a headless-browser fetch, and note which in the PR
|
||||||
|
- [x] Extend `model_presets.json` per model with: `hf_aliases` (curated list of comparable HF model+provider IDs — **human-verified, not auto-discovered**), `hf_verified_match_note` (free text: params count + quantization confirmation, so a human signs off once per alias that it is a fair comparable before it's used for auto-pricing), `hf_last_price_per_1k` (derived from the $/1M rate), `hf_last_updated` (ISO date)
|
||||||
|
- [x] Daily refresh job reusing the tracker's existing daemon-thread pattern (`_settlement_loop`/`_deposit_loop` in `server.py`, `threading.Event().wait(interval)` loop) — for each preset with a non-empty `hf_aliases` list, fetch current pricing for those aliases, compute `0.8 × cheapest matched alias price`, call `set_price()`, and update `hf_last_price_per_1k`/`hf_last_updated`
|
||||||
|
- [x] Every price change logged (old price, new price, source alias, timestamp) — needed for dispute auditability if a client questions a charge
|
||||||
|
- [x] Fallback behavior: empty/missing `hf_aliases`, fetch failure, or no verified match → silently keep the existing static default price. Never error the pricing path, never zero-price a model
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] At least one model preset has a working end-to-end refresh (alias → live fetch → 80% computed price → `set_price()` called → metadata updated) demonstrated in a test
|
||||||
|
- [x] Models without a curated/verified alias continue to use the static default, unaffected by this feature
|
||||||
|
- [x] Fetch failures (network error, page structure change, no match found) degrade gracefully — logged, not raised to the request path
|
||||||
|
- [x] Price-change log is queryable/inspectable (doesn't need a UI yet — a log line or table row is sufficient for alpha)
|
||||||
|
- [x] Note in the runbook/issue on which fetch mechanism (plain HTTP scrape vs. headless browser) was actually required, so the next person doesn't have to rediscover it
|
||||||
|
|
||||||
|
## Implementation notes (2026-07-06)
|
||||||
|
|
||||||
|
**Fetch mechanism confirmed: plain HTTP scrape, no headless browser needed.** Live-fetched `https://huggingface.co/inference/models?search=GLM` this session — the pricing table is server-rendered into the initial HTML response (SvelteKit SSR), confirmed by grepping the raw response for `cheapest`/`$`-prefixed price cells before any JS runs. A stdlib `urllib.request` GET + `html.parser.HTMLParser`-based table walk is sufficient; no `requests`/`bs4`/Playwright dependency was added, matching this package's existing zero-new-HTTP-dependency convention (`gossip.py`/`raft.py`/`server.py` all use `urllib.request` only). Each row's most stable extraction anchor turned out to be the `<a href="/<org>/<repo>/?inference_api=true&inference_provider=<provider>">` link, not the display text (which duplicates the repo id at two responsive breakpoints and is easy to mis-parse).
|
||||||
|
|
||||||
|
**What shipped:** new `packages/tracker/meshnet_tracker/hf_pricing.py` — pure HTML parser (`parse_hf_pricing_table`), alias matching (`cheapest_matching_quote`, supports both `org/repo` and `org/repo::provider` forms so a human can pin a specific provider's deployment), a pure per-preset computation function (`refresh_preset_price`, never raises), and `HfPricingLog` (SQLite-backed change log, same shape as `billing.py`/`calibration.py`). `TrackerServer` gained an opt-in (`enable_hf_pricing=True` / `--enable-hf-pricing`) daily daemon thread (`_hf_pricing_loop`, same `threading.Event().wait(interval)` shape as `_settlement_loop`) and `GET /v1/pricing/hf/history` (admin/validator-gated, mirrors `/v1/calibration/toploc/results`). `model_presets.json`'s `kimi-k2.7` preset now carries the `hf_aliases`/`hf_verified_match_note` schema fields, left as an empty list pending a human sign-off on a genuinely comparable HF listing (params count + quantization) — per this issue's own "human-verified, not auto-discovered" requirement, an agent should not fabricate that sign-off. This also means the shipped default config demonstrates the required "no alias → static price, unaffected" fallback for a real production preset; the alias→live-fetch→80%→set_price() path is demonstrated end-to-end against an injected fetch backend in `tests/test_hf_pricing_dispatch.py` (the `fetch_html=`/`hf_pricing_fetch_html=` injection point mirrors this codebase's `backend=` convention for anything that would otherwise hit the network in tests).
|
||||||
|
|
||||||
|
**Bug caught and fixed while wiring this in:** `TrackerServer` previously did `dict(DEFAULT_MODEL_PRESETS)` when no explicit `model_presets` was passed — a shallow copy that aliases every preset's inner dict to the shared module-level global. Writing `hf_last_price_per_1k`/`hf_last_updated` in place would have leaked across every other `TrackerServer` instance in the same process (real risk in the test suite, and in any future multi-tracker-in-one-process embedding). Fixed with a `_clone_model_presets()` helper that also shallow-copies each preset dict.
|
||||||
|
|
||||||
|
**Follow-up for a human (not a completion blocker):** populate real `hf_aliases`/`hf_verified_match_note` entries for production presets once someone has confirmed a genuinely comparable HF-listed deployment (params + quantization) — that activates dynamic pricing for that model on the next refresh tick. Until then every preset safely stays on its static price.
|
||||||
|
|
||||||
|
Tests: `tests/test_hf_pricing.py` (11 tests: parsing, blended-price math, alias matching incl. provider-scoped aliases, all three fallback paths, log persistence) + `tests/test_hf_pricing_dispatch.py` (5 tests: full TrackerServer end-to-end refresh, unaffected-without-alias, history auth gating, history content, history model filter). Full suite (`pytest tests/ -q -k "not integration"`): 346 passed, 2 skipped.
|
||||||
|
|
||||||
|
## ADR links
|
||||||
|
|
||||||
|
- [ADR-0015](../../docs/adr/0015-usdt-custodial-settlement.md) — settlement/pricing this touches (90/10 split, per-model pricing)
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
None — independent of the alpha-hardening trust-boundary work; touches `billing.py`/`server.py` pricing paths only.
|
||||||
|
|
||||||
|
## Blocks
|
||||||
|
|
||||||
|
None — ship-soon for launch quality, not a release gate (see status note above).
|
||||||
491
.scratch/alpha-hardening/prd.json
Normal file
491
.scratch/alpha-hardening/prd.json
Normal file
File diff suppressed because one or more lines are too long
281
.scratch/alpha-hardening/research-verifiable-inference.md
Normal file
281
.scratch/alpha-hardening/research-verifiable-inference.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Verifiable Inference & Fraud Layer — Research Findings
|
||||||
|
|
||||||
|
**Date:** 2026-07-04
|
||||||
|
**Scope:** Ground the neuron-tai fraud/verification layer in 2026 state-of-the-art and in what comparable decentralized compute networks actually do.
|
||||||
|
**Alpha context assumed throughout:** volunteer consumer GPUs (8 GB class, e.g. RTX 4060), pipeline-parallel multi-hop inference where different nodes hold different layer ranges, a single trusted tracker (coordinator), devnet mock-USDT payments but reputation that carries forward, and an owner-approved verification budget of roughly 5% redundant recomputation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive summary
|
||||||
|
|
||||||
|
1. **The proven, deployed pattern for exactly our setting is: optimistic acceptance + random teacher-forced re-verification + slashing/reputation penalties.** This is what Prime Intellect runs in production for INTELLECT-2 (TOPLOC validators, random sampling, slash-and-evict) and what Hyperbolic formalized game-theoretically (Proof of Sampling). At a 5% audit rate the math works if the penalty for a caught cheat exceeds ~19× the per-job gain — which carried-forward reputation plus forfeited pending balance easily provides.
|
||||||
|
2. **Teacher-forced logit/activation re-verification is a recognized, robust technique** — it is the core of TOPLOC (ICML 2025), Token-DiFR (2025), and log-probability auditing papers. It sidesteps autoregressive divergence entirely and is up to ~100× cheaper than the original generation because verification prefill is compute-bound while generation is memory-bound.
|
||||||
|
3. **Bitwise output equality is unattainable across heterogeneous volunteer GPUs**; honest nodes on different hardware/batch conditions produce different tokens even at temperature 0. All robust schemes compare in *logit/activation space with tolerances* (TOPLOC exponent/mantissa thresholds, DiFR divergence scores) or force bitwise reproducibility with special kernels (Gensyn RepOps) at a performance cost.
|
||||||
|
4. **zkML and GPU TEEs are roadmap-only.** zkML in 2025–2026 proves GPT-2-scale models in tens of seconds per inference (100–10,000× overhead); nothing near serving speed for multi-billion-parameter models. GPU confidential computing exists only on H100/Blackwell-class datacenter hardware paired with server CPUs (SEV-SNP/TDX) — categorically unavailable on consumer volunteer cards.
|
||||||
|
5. **Layer-skipping is NOT reliably detectable by output quality.** Careful pruning of ~25% of layers keeps ~90% of benchmark scores (ShortGPT); models degrade gracefully until a sharp collapse around 20–55% removal depending on family. But any layer skipping changes hidden states and logits drastically, so per-position activation/logit comparison catches it essentially always (TOPLOC reports 100% detection of model modifications in its evals). Verify in logit space, not by eyeballing text quality.
|
||||||
|
6. **Classic PoW is Sybil/hardware admission control, not correctness proof** — io.net's hourly PoW is the cautionary tale (it verifies a GPU exists and has claimed VRAM, nothing about job correctness). Self-computed activation checksums are *binding commitments*, not correctness proofs: a cheater simply commits to its wrong values. Commitments earn their keep only when a referee later recomputes and compares — i.e., for audit pinning and dispute bisection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Technique-by-technique review
|
||||||
|
|
||||||
|
### 1.1 Optimistic verification + spot-checking (probabilistic deterrence)
|
||||||
|
|
||||||
|
**Mechanism.** Accept results by default; re-run a random, unpredictable subset with an independent replica; punish divergences. Hyperbolic's Proof of Sampling (PoSP) paper proves a pure-strategy Nash equilibrium in which rational nodes are honest, and claims the spot-check approach adds well under 1% overhead when everyone is rational ([PoSP, arXiv:2405.00295](https://arxiv.org/html/2405.00295); [Hyperbolic spML breakdown](https://www.hyperbolic.ai/blog/spml-breakdown)). VeriLLM adds VRF-selected audit indices over Merkle-committed hidden states so verifiers can't be lazy or predictable ([VeriLLM, arXiv:2509.24257](https://arxiv.org/html/2509.24257v3)). Prime Intellect's production deployment: validators randomly sample committed batches, and "since the Inference Provider does not know which generations will be checked, they are incentivized to be honest on all generations" ([INTELLECT-2, arXiv:2505.07291](https://arxiv.org/html/2505.07291)).
|
||||||
|
|
||||||
|
**Sampling math.** With independent audit probability *p* per job:
|
||||||
|
|
||||||
|
- A node that cheats on every job survives *N* jobs undetected with probability (1−p)^N. At p = 0.05: ~36% survive 20 jobs, ~4.6% survive 60 jobs, ~0.6% survive 100 jobs. Detection is near-certain over any meaningful volume.
|
||||||
|
- A node that cheats on a fraction *f* of jobs is caught per job with probability *p·f*; expected jobs until caught = 1/(p·f). At p = 0.05, f = 0.2: caught within ~100 jobs on average. Low-rate cheating stretches time-to-detection linearly — this is why the penalty must scale with accumulated stake/reputation, not per-incident.
|
||||||
|
- **Deterrence condition (rational node):** cheating gains *g* per job and loses *L* when caught, so expected value of cheating is (1−p)·g − p·L. Honesty dominates iff **L > g·(1−p)/p ≈ 19·g at p = 0.05**. If a caught node forfeits its pending balance and a reputation that gates future routing (i.e., discounted future earnings), L is orders of magnitude above 19× a single job's margin. This is exactly the structure PoSP formalizes and the existing neuron-tai forfeiture design (pending-balance forfeiture + strikes + ban) already implements.
|
||||||
|
|
||||||
|
**The verifier-incentive caveat.** Truebit identified the classic failure mode: if the system works and nobody cheats, verifiers never earn anything and stop checking; Truebit's answer was "forced errors" with jackpot payouts, and it estimated the verification tax at 500–5000% of task cost to keep independent verifiers attentive ([Truebit whitepaper §2, §4](http://people.cs.uchicago.edu/~teutsch/papers/truebit.pdf)). **This whole problem disappears in a single-tracker alpha**: the tracker audits as a protocol function paid from the protocol cut, not as a profit-seeking third party. It becomes relevant again only when verification is decentralized.
|
||||||
|
|
||||||
|
**Feasibility for alpha: HIGH.** This is the anchor technique. 5% audit budget is in the same range as deployed systems (OTR proposes ρ ≈ 1% spot-checks as sufficient deterrent in a TEE-hybrid design — [arXiv:2512.20176](https://doi.org/10.48550/arxiv.2512.20176)); 5% gives comfortable margin for a young network with thin reputational stakes.
|
||||||
|
|
||||||
|
### 1.2 Interactive verification / refereed delegation (Truebit-style bisection)
|
||||||
|
|
||||||
|
**Mechanism.** Solver commits Merkle roots of intermediate execution states. On dispute, challenger and solver binary-search over the committed states to the first step where they diverge; a referee (smart contract or trusted party) recomputes only that single step and rules. One honest challenger suffices; the game costs O(log n) rounds ([Truebit whitepaper](http://people.cs.uchicago.edu/~teutsch/papers/truebit.pdf); [Truebit explainer, Medium](https://medium.com/truebit/truebit-the-marketplace-for-verifiable-computation-f51d1726798f)).
|
||||||
|
|
||||||
|
**ML adaptation.** Gensyn's Verde adapts this to neural networks with a two-phase bisection: first narrow to the training/inference *step*, then to the single *operator* in the computational graph; the referee recomputes one operator, needing ~two orders of magnitude less compute than the full job. Crucially, Verde only works because RepOps makes execution **bitwise reproducible across heterogeneous hardware** by fixing floating-point operation order — otherwise honest nodes diverge and bisection finds spurious "fraud" ([Verde paper, arXiv:2502.19405](https://arxiv.org/html/2502.19405v1); [Gensyn Verde blog](https://blog.gensyn.ai/verde-a-verification-system-for-machine-learning-over-untrusted-nodes/)).
|
||||||
|
|
||||||
|
**Mapping to our multi-hop pipeline.** Our pipeline is naturally pre-bisected: each hop's boundary activations are the "intermediate states." A workable scheme:
|
||||||
|
|
||||||
|
1. Each node commits a compact fingerprint (TOPLOC-style top-k encoding, not a raw hash — see §2) of its input and output boundary activations per request.
|
||||||
|
2. When an end-to-end audit fails, the referee (tracker) teacher-forces the full claimed token sequence through a reference model and computes reference boundary activations at each hop cut-point in one forward pass.
|
||||||
|
3. The first hop whose committed output fingerprint diverges from the reference (beyond tolerance) while its input fingerprint matched is the culprit. No interactive game is needed at hop granularity because the number of hops is small — the referee can check all cut-points in a single replay.
|
||||||
|
4. Bisection *within* a hop (to a layer or operator, Verde-style) is only needed if you must prove fault to a third party at fine granularity; for internal blame assignment, hop granularity is enough since payment and reputation are per node.
|
||||||
|
|
||||||
|
The main adaptation cost is the referee needing enough VRAM to run the full model (or to re-run hop layer-ranges one at a time, which any 8 GB card can do for its own range).
|
||||||
|
|
||||||
|
**Feasibility for alpha: MEDIUM-HIGH** in the simplified "referee replays and compares committed hop boundaries" form (no on-chain game, tracker as referee). The full trustless interactive game with bitwise-reproducible kernels is roadmap.
|
||||||
|
|
||||||
|
### 1.3 zkML (zero-knowledge proofs of inference)
|
||||||
|
|
||||||
|
**2025–2026 state.** The frontier moved fast but remains far from LLM serving speed:
|
||||||
|
|
||||||
|
- EZKL (ONNX→Halo2) is the most-used toolkit; generic overhead is characterized as 100–10,000× native execution, and much real usage is verifiable data science rather than LLMs ([Equilibrium Labs survey](https://equilibrium.co/writing/state-of-verifiable-inference)).
|
||||||
|
- zkGPT (USENIX Security 2025) proves a GPT-2 inference in **under 25 seconds** on a CPU server — 185× faster than the prior ZKML system ([zkGPT](https://www.usenix.org/conference/usenixsecurity25/presentation/qu-zkgpt)).
|
||||||
|
- NanoZK (2026) proves GPT-2-scale transformer blocks in ~43 s with 6.9 KB proofs via layerwise decomposition, 52–228× faster than EZKL ([NanoZK, arXiv:2603.18046](https://arxiv.org/html/2603.18046v1)).
|
||||||
|
- For a 7B model, one estimate puts pure-zkML verification at **over 20 minutes per inference** ([Optimistic TEE-Rollups, arXiv:2512.20176](https://doi.org/10.48550/arxiv.2512.20176)).
|
||||||
|
- Project reality check: Modulus Labs (authored the field-defining benchmark ["The Cost of Intelligence"](https://eprint.iacr.org/2026/1063), demonstrated proving of a multi-billion-parameter LLM as a feat, not a service) was acquired by Tools for Humanity in 2024 and now does World ID cryptography ([announcement](https://world.org/blog/announcements/modulus-labs-joins-tfh-support-applied-research-world)). Giza uses ZK proofs for *small, simple* DeFi-agent models on Starknet ([Equilibrium survey](https://equilibrium.co/writing/state-of-verifiable-inference)). EZKL itself has drifted toward verifiable data science and co-SNARKs.
|
||||||
|
|
||||||
|
**Honest read:** proving is ~GPT-2 scale (~1.5B params max in tens of seconds), on beefy servers, per short inference. For a volunteer network serving multi-billion-parameter models interactively, zkML is 3+ orders of magnitude away, and proof generation would crush an 8 GB consumer card regardless.
|
||||||
|
|
||||||
|
**Feasibility for alpha: NONE (roadmap-only).**
|
||||||
|
|
||||||
|
### 1.4 TEE / confidential computing on GPUs
|
||||||
|
|
||||||
|
**What it gives.** NVIDIA H100 is the first GPU with a hardware TEE anchored in an on-die root of trust: measured/secure boot, SPDM session with a driver inside a CPU confidential VM, and a signed **attestation report** covering GPU firmware/state that a remote party verifies against NVIDIA's attestation service before releasing work ([NVIDIA blog](https://developer.nvidia.com/blog/confidential-computing-on-h100-gpus-for-secure-and-trustworthy-ai/); [CACM: Creating the First Confidential GPUs](https://cacm.acm.org/practice/creating-the-first-confidential-gpus/)). Combined with CPU TEE attestation (AMD SEV-SNP or Intel TDX measuring the VM image), you get a verifiable claim that *a specific measured software stack loaded a specific model and ran on genuine hardware*, with only ~2–6% overhead (Blackwell figures, [GPUYard setup guide](https://www.gpuyard.com/tutorials/howto/nvidia-blackwell-confidential-computing-setup/)) or ~5–10% generally ([Equilibrium survey](https://equilibrium.co/writing/state-of-verifiable-inference)). Phala runs exactly this to sell "verifiable LLMs" through OpenRouter ([Phala blog](https://phala.com/posts/GPU-TEEs-is-Alive-on-OpenRouter)).
|
||||||
|
|
||||||
|
**Caveats.** Attestation proves the *environment*, not the mathematics — you trust NVIDIA/AMD/Intel hardware and are exposed to side channels and firmware bugs (the OTR paper layers ZK spot-checks on top of TEEs precisely because of compromised-TEE risk). And critically for us: CC mode requires Hopper/Blackwell datacenter GPUs plus server platforms with SEV-SNP/TDX enabled in BIOS and the open kernel modules ([NVIDIA deployment guide](https://docs.nvidia.com/cc-deployment-guide-tdx-snp.pdf)). **No RTX consumer card supports it.** Volunteer nodes on 4060-class hardware categorically cannot provide GPU TEE attestation; Intel SGX on client CPUs is likewise not a path (deprecated on consumer parts, and it would only cover CPU-side code anyway).
|
||||||
|
|
||||||
|
**Feasibility for alpha: NONE for volunteer nodes.** Roadmap option: a *TEE tier* — if datacenter H100/Blackwell operators ever join, attest them and route confidentiality-sensitive or high-value jobs there; also usable for the tracker's own reference validator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The non-determinism problem for output comparison
|
||||||
|
|
||||||
|
### 2.1 Why honest nodes disagree
|
||||||
|
|
||||||
|
- **Floating-point non-associativity** is the root cause, but the *mechanism* that surfaces it is batch-variant kernels: inference servers pick different kernels/split strategies depending on batch size and load, changing reduction order and hence results — the same request returns different logits depending on what else is in the batch ([Thinking Machines: Defeating Nondeterminism in LLM Inference](https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/)).
|
||||||
|
- **Even greedy (temperature-0) decoding diverges across configurations**: changing batch size, GPU count, GPU architecture, or tensor-parallel size measurably changes outputs under greedy decoding, and small numerical differences get **amplified autoregressively** — one flipped token early in a long generation cascades into a completely different continuation ([arXiv:2506.09501](https://arxiv.org/html/2506.09501v2); [arXiv:2511.17826](https://arxiv.org/html/2511.17826v2)).
|
||||||
|
- Temperature > 0 sampling adds outright randomness on top unless seeds and the sampling algorithm (e.g. Gumbel-max in vLLM) are synchronized.
|
||||||
|
|
||||||
|
**Consequence:** naive "re-run the prompt, compare the text" audits will flag honest heterogeneous nodes as cheaters. Never compare free-running generations token-for-token across different hardware.
|
||||||
|
|
||||||
|
### 2.2 Techniques that make honest-vs-honest comparison robust
|
||||||
|
|
||||||
|
1. **Teacher-forced re-verification (per-position logit/activation check) — yes, this is a recognized, state-of-the-art technique.**
|
||||||
|
- **TOPLOC** (Prime Intellect, ICML 2025): the prover commits top-k values/indices of the last hidden state per decode chunk (258 bytes per 32 tokens, ~1000× smaller than raw activations); the validator *re-runs the claimed token sequence as a single prefill* (teacher forcing) and checks the committed top-k against recomputed values using exponent/mantissa error thresholds. Reported: 100% detection of model, prompt, or precision substitutions with zero false positives across different GPUs, tensor-parallel layouts, and attention implementations, and validation up to **100× faster than the original inference** because prefill is compute-bound while decode is memory-bound ([TOPLOC, arXiv:2501.16007](https://arxiv.org/html/2501.16007v1); [GitHub](https://github.com/PrimeIntellect-ai/toploc)).
|
||||||
|
- **Token-DiFR / Activation-DiFR** (2025): synchronize the sampling seed (Gumbel-max), then compare the provider's tokens against a reference re-generation — with a shared seed, >98% of tokens match exactly for honest providers, so token divergence itself becomes the audit signal; detects 4-bit quantization with AUC > 0.999 within 300 tokens. Activation-DiFR compresses activations with random orthogonal projections and detects the same with 2 tokens. Released as a vLLM integration ([DiFR, arXiv:2511.20621](https://arxiv.org/pdf/2511.20621); [author explainer](https://technicallyprivate.substack.com/p/token-difr-llm-inference-verification)).
|
||||||
|
- **Log-probability auditing**: comparing per-token logprob distributions against a reference model detects even single-step fine-tuning or quantization changes; simple statistical tests (permutation test on per-token mean logprobs, KS tests) overcome logprob noise ([Logprob Tracking, arXiv:2512.03816](https://arxiv.org/html/2512.03816v1); [model-substitution audit, arXiv:2504.04715](https://arxiv.org/html/2504.04715v1)).
|
||||||
|
2. **Tolerance-based matching, not bitwise equality.** TOPLOC accepts bounded exponent-intersection and mantissa-error deviations; DiFR scores divergence-from-reference against an honest-noise baseline. Both are explicitly designed so cross-GPU numerical noise passes while quantization/model swaps fail.
|
||||||
|
3. **Beware scalar aggregate thresholds alone.** The DiFR authors note that a single statistic like mean cross-entropy can be gamed — a malicious provider can tune its sampling temperature until the aggregate matches expectation. Seed-synchronized token matching or per-position top-k activation checks leave far fewer degrees of freedom ([DiFR explainer](https://technicallyprivate.substack.com/p/token-difr-llm-inference-verification)).
|
||||||
|
4. **Bitwise-deterministic kernels** (Gensyn RepOps; Thinking Machines' batch-invariant kernels; TBIK for cross-tensor-parallel invariance) make exact comparison possible but require replacing the whole operator stack and sacrifice performance — practical for a controlled verification environment, unrealistic to impose on volunteer nodes running stock llama.cpp/transformers ([Gensyn](https://blog.gensyn.ai/verde-a-verification-system-for-machine-learning-over-untrusted-nodes/); [Thinking Machines](https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/); [arXiv:2511.17826](https://arxiv.org/html/2511.17826v2)).
|
||||||
|
|
||||||
|
**Practical recipe for our audits:** record prompt, claimed output tokens, sampling params, and per-hop activation fingerprints. Audit = one teacher-forced forward pass of the claimed tokens on a reference node; compare per-position: (a) hop-boundary fingerprints TOPLOC-style, and/or (b) whether each claimed token is plausible under the reference distribution (rank/logprob within tolerance; exact match if seeds are synchronized). This is cheap (single prefill), robust to hardware noise, and immune to autoregressive divergence because the token sequence is fixed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Layer-skipping detectability
|
||||||
|
|
||||||
|
**How gracefully do transformers degrade when layers are dropped?**
|
||||||
|
|
||||||
|
- **Careful pruning degrades subtly.** ShortGPT removes ~25% of layers (chosen by Block Influence, training-free) and keeps ~90% of benchmark performance — e.g. LLaMA2-13B MMLU drops only 55.0→52.2 after removing 10 of 40 layers ([ShortGPT, arXiv:2403.03853](https://arxiv.org/pdf/2403.03853)). Gromov et al. find a "characteristic flat region of robust performance" on QA benchmarks followed by a **sharp collapse to random accuracy** at a model-dependent threshold: ~45–55% of layers for Llama-2 family, ~35% for Mistral-7B, ~25% for Phi-2, ~20% for Qwen — and up to ~half of Llama-2-70B's layers with healing finetuning ([The Unreasonable Ineffectiveness of the Deeper Layers, arXiv:2403.17887](https://arxiv.org/html/2403.17887v1)). LayerDrop showed a decade of precedent: networks *trained* with layer dropout can be pruned at inference with modest loss ([arXiv:1909.11556](https://arxiv.org/abs/1909.11556)).
|
||||||
|
- **So: would skipping ~36 of 100 layers produce gibberish?** Probably not gibberish. For a Llama-like model, 36% removal of *well-chosen* layers sits below the collapse threshold — output would be degraded but often fluent, and on easy prompts might look acceptable. A lazy cheater skipping a contiguous middle block *without* choosing layers carefully does worse — Gromov et al. show the naive heuristic's loss "begins to increase very rapidly even with small amounts of pruning" before healing — but "worse" still means plausible-looking text on many prompts, not obvious garbage. **Human-eyeball or coarse quality checks are not a reliable fraud detector.**
|
||||||
|
- **Statistical detection: yes, and easily.** Without healing, C4 validation loss rises sharply with pruning fraction (Gromov et al. Fig. 3), so perplexity of the node's outputs under a reference model shifts detectably given enough tokens. But the far stronger signal is direct: skipping any layer changes the hidden states and output logits at *every* position by amounts vastly exceeding floating-point noise. TOPLOC's evaluation explicitly includes detecting modified models with 100% accuracy; DiFR detects even 4-bit quantization (a much subtler perturbation than deleting layers) with AUC > 0.999 in ≤300 tokens. **A single teacher-forced audit pass catches layer-skipping essentially deterministically.**
|
||||||
|
- **Pipeline nuance:** in multi-hop inference, a node that skips layers inside its assigned range corrupts its output boundary activations; the hop-boundary fingerprint comparison in §1.2 localizes exactly which node did it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Why classic PoW does not solve verifiable useful computation
|
||||||
|
|
||||||
|
- **PoW's defining property is verification asymmetry over an *arbitrary* puzzle**: work is hard to produce, trivially cheap to check (hash preimage with leading zeros), and the puzzle content is irrelevant — it exists purely to price Sybil identities and block production ([Wikipedia: Proof of work](https://en.wikipedia.org/wiki/Proof_of_work)). Useful computation generally lacks this asymmetry: checking an ML result naively requires re-executing it, which is the whole problem. This is the central, repeatedly confirmed obstacle in the Proof-of-Useful-Work literature — "the main weakness that all presented PoUW approaches have in common is the verification of results" ([Challenges of PoUW, arXiv:2209.03865](https://doi.org/10.48550/arxiv.2209.03865); [SoK: Is Proof-of-Useful-Work Really Useful?, IACR 2025/1814](https://eprint.iacr.org/2025/1814.pdf)). Expensive verification then triggers the **verifier's dilemma**: rational verifiers skip checking, and security collapses ([Truebit whitepaper §1](http://people.cs.uchicago.edu/~teutsch/papers/truebit.pdf)).
|
||||||
|
- **Live case study:** io.net's hourly "Proof-of-Work" makes GPUs solve hash puzzles and proves *the hardware exists, has the claimed VRAM, and is online* ([io.net PoW docs](https://io.net/docs/guides/workers/proof-of-work)). It was bolted on after the April 2024 Sybil attack (~1.8M fake GPUs spoofed via a shared auth token to farm airdrops) and says nothing about whether any customer job was computed correctly ([io.net 2026 analysis](https://cryptoaianalysis.com/io-net-io-analysis-2026/)). PoW = admission control and capacity attestation; correctness needs a separate mechanism.
|
||||||
|
- **Self-computed checksums/commitments: confirmed — no standalone correctness guarantee.** A hash or Merkle commitment over activations is *binding* (the node can't later change its story) and possibly *hiding*, but the committed values are whatever the node computed — a malicious node honestly commits to its dishonest activations, and the checksum verifies perfectly. Correctness only enters when an independent party recomputes and compares against the commitment. That is precisely how the serious systems use commitments: Truebit's Merkle state roots and Verde's checkpoint hashes exist to make **dispute bisection** possible and to **pin claims before an unpredictable audit** (so a node can't retroactively fix its answer once it learns it's being checked), never as proof by themselves ([Truebit](http://people.cs.uchicago.edu/~teutsch/papers/truebit.pdf); [Verde, arXiv:2502.19405](https://arxiv.org/html/2502.19405v1); TOPLOC commitments are verified by validator recomputation — [arXiv:2501.16007](https://arxiv.org/html/2501.16007v1)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What comparable networks actually do
|
||||||
|
|
||||||
|
| Network | Verification of outputs | Sybil / fraud handling | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Prime Intellect** | TOPLOC activation commitments per sequence; validators teacher-force-recompute (sampled, up to 100× faster than generation); plus sampling-sanity and data-sanity checks | Invalid batches → node **slashed and evicted** from compute pool; hardware checks at registration | Deployed at scale for INTELLECT-2 (32B RL run over permissionless nodes) ([arXiv:2505.07291](https://arxiv.org/html/2505.07291)) |
|
||||||
|
| **Gensyn** | Verde refereed delegation: bisect disputes to a single operator; referee recomputes it; requires RepOps bitwise-reproducible kernels | Correct result guaranteed if ≥1 of the assigned providers is honest; economic penalties on losers of disputes | The most rigorous trustless design; reproducibility stack is the price ([Verde](https://arxiv.org/html/2502.19405v1); [docs](https://docs.gensyn.ai/core-components)) |
|
||||||
|
| **Bittensor** | No direct output verification: subnet **validators score miners**; Yuma Consensus aggregates stake-weighted scores into emissions | Registration costs + stake; the notorious **weight-copying** problem (validators free-riding by copying consensus weights) countered by Commit-Reveal v3: timelock-encrypted weights revealed epochs later so copiers only get stale data ([weight copying](https://docs.learnbittensor.org/concepts/weight-copying-in-bittensor); [commit-reveal](https://docs.learnbittensor.org/concepts/commit-reveal)) | Verification quality is per-subnet and heuristic; consensus rewards conformity, not proven correctness |
|
||||||
|
| **io.net** | None for job outputs | Hourly hash-puzzle PoW + VRAM check + Proof of Time-Lock (uptime), added after the Apr 2024 fake-GPU Sybil attack; failed checks → excluded from rewards/hiring ([PoW docs](https://io.net/docs/guides/workers/proof-of-work); [Messari overview](https://messari.io/report/understanding-io-net-a-comprehensive-overview)) | Marketplace model: hardware attestation only, correctness left to the customer |
|
||||||
|
| **Ritual (Infernet)** | Modular: nodes may attach ZK, optimistic, or TEE proofs; consumer contracts opt in ([Ritual blog](https://ritual.net/blog/celestia)) | ChainLight's security review: base Infernet has **no aggregation of multiple nodes, no reputation, no penalty for malicious nodes returning wrong results** ([ChainLight](https://blog.chainlight.io/ecosystem-explorer-exploring-security-risks-in-ai-blockchain-projects-2c490a726d13)) | "Verification-optional" in practice |
|
||||||
|
| **Hyperbolic** | PoSP/spML: random spot-check by a second node; disagreement → arbitration; slashing of the dishonest party ([PoSP, arXiv:2405.00295](https://arxiv.org/html/2405.00295)) | Nash-equilibrium argument that rational nodes stay honest; validator anonymity until finalization to resist collusion | Closest published formalization of our intended scheme |
|
||||||
|
| **Akash** | None (general compute marketplace) | **Auditor-attested provider tiers**: governance-approved auditors post on-chain attestations; no provider staking/slashing — ChainLight flags malicious-provider risk as an accepted gap ([ChainLight](https://blog.chainlight.io/ecosystem-explorer-exploring-security-risks-in-ai-blockchain-projects-2c490a726d13); [Akash docs](https://akash.network/docs/providers/getting-started/should-i-run-a-provider/)) | Reputation = audit badge + market history |
|
||||||
|
| **Petals** (closest architectural cousin: volunteer, pipeline-parallel, consumer GPUs) | **None** — the paper and docs openly state malicious peers can alter outputs; mitigation is "use a private swarm of people you trust"; devs planned a centralized points system for nodes that "consistently return correct results" ([GitHub](http://github.com/bigscience-workshop/petals); [dev comments on HN](https://news.ycombinator.com/item?id=34215665)) | Proof that the architecture works, and that the fraud layer is the missing piece we're building |
|
||||||
|
| **Together AI** | Centralized provider; no public trustless verification protocol — trust is contractual/reputational | n/a | Included for contrast: the "trusted provider" baseline |
|
||||||
|
| **Atoma / Ambient** (emerging) | Atoma: TEE where available, else user-configurable redundant sampling; Ambient: "Proof of Logits" fingerprinting with ~0.1% claimed overhead but no open implementation ([Equilibrium survey](https://equilibrium.co/writing/state-of-verifiable-inference)) | — | Signal of where the field is converging: logit fingerprints + sampling |
|
||||||
|
|
||||||
|
**Pattern:** nobody in production uses zkML for LLM serving; nobody offers TEE on consumer GPUs; every serious inference-verification deployment converges on *commit → sampled teacher-forced recompute → slash/reputation*, with refereed delegation as the trustless escalation path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Reputation systems for routing and audit-rate weighting
|
||||||
|
|
||||||
|
**Foundations.** EigenTrust computes global trust as the principal eigenvector of normalized local trust ratings and remains the canonical design; in simulation it suppresses malicious collectives up to large fractions of the network, but it depends on pre-trusted peers and majority-honest aggregation ([EigenTrust, WWW'03](https://nlp.stanford.edu/pubs/eigentrust.pdf)).
|
||||||
|
|
||||||
|
**Documented pitfalls** (from EigenTrust's own threat models and the P2P reputation-attack literature — [survey](https://cnitarot.github.io/papers/p2p-reputation-survey.pdf)):
|
||||||
|
|
||||||
|
- **Sybil flooding / whitewashing:** free identities let attackers dominate the "unknown newcomer" pool and shed bad reputations by rejoining. Defense: entry cost (stake, invite, proof-of-hardware), newcomer probation with low routing weight and *elevated* audit rates.
|
||||||
|
- **Reputation farming / milking:** behave honestly on cheap jobs to build score, then cash in on expensive ones ("moles" that interleave honest work are documented in real systems like Maze). Defense: value-weighted reputation (a job's reputation contribution ∝ its audited value), and audit probability that never floors at zero for anyone.
|
||||||
|
- **Collusion rings:** peers mutually inflating ratings. Defense: in our alpha, reputation should derive **only from tracker-verified audit outcomes**, never from peer ratings — this deletes the collusion surface EigenTrust suffers from. (EigenTrust++ hardens propagation with feedback-similarity for when peer ratings do get introduced — [EigenTrust++](https://doi.org/10.4108/icst.collaboratecom.2012.250420).)
|
||||||
|
- **Weight-copying (Bittensor's lesson):** any published evaluation signal will be free-ridden by lazy evaluators; the fix is delaying/encrypting evaluations (commit-reveal timelock) until they're stale ([Bittensor docs](https://docs.learnbittensor.org/concepts/weight-copying-in-bittensor)). Relevant to us only when third-party validators exist; a single tracker has no one to copy from.
|
||||||
|
|
||||||
|
**Reputation-weighted routing + audit rates in practice.** io.net gates hiring and block rewards on rolling verification status; Akash routes via audit-tier filters; Bittensor routes emissions via consensus-weighted scores. The clean design for us, consistent with PoSP-style deterrence:
|
||||||
|
|
||||||
|
- **Routing weight** increases with reputation (and observed TPS, already tracked), so honest nodes earn more — making L (loss-on-catch, §1.1) grow with tenure.
|
||||||
|
- **Audit rate** decreases with reputation but has a hard floor (e.g. new node 20–30% → veteran 2–3%, floor ≥ 2%), keeping the overall budget ≈ 5% while making cheating-as-a-newcomer maximally risky and reputation-then-defect still irrational.
|
||||||
|
- **Asymmetric dynamics:** slow to build, instant to lose (existing 3-strike forfeiture design already matches this), plus decay on inactivity so parked identities can't hoard trust.
|
||||||
|
- **Unpredictability is load-bearing:** audit selection must be indistinguishable from normal traffic at request time (same request format, decided by tracker RNG/VRF after commitment), or nodes cheat only on non-audited requests — the exact property both PoSP and INTELLECT-2 rely on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Feasibility table for the neuron-tai alpha
|
||||||
|
|
||||||
|
Alpha constraints: volunteer 8 GB consumer GPUs, single trusted tracker, mock-USDT but real carried-forward reputation, ~5% audit budget, small models today (0.5B-class) scaling up.
|
||||||
|
|
||||||
|
| Technique | Alpha feasibility | Overhead | What it buys | Verdict |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Optimistic accept + random teacher-forced audit (~5%) | **High** — tracker (or a trusted validator node) re-runs claimed tokens as one prefill | ~5% of serving compute; audits themselves up to ~100× cheaper than original generation | Detects wrong model, quantization, layer-skipping, garbage outputs; rational-cheating deterrence at L > 19× per-job gain | **Do now (anchor)** |
|
||||||
|
| Per-hop activation fingerprints (TOPLOC-style top-k, tolerance matching) | **High** — few hundred bytes per response per hop; nodes already stream activations | Negligible at inference; storage ~258 B/32 tokens/hop | Pins claims before audit; localizes blame to the exact hop on audit failure | **Do now** |
|
||||||
|
| Hop-boundary replay "bisection" (referee = tracker, non-interactive) | **Medium-high** — needs a reference node able to run the full model or per-hop ranges | Only on audit failure / dispute | Culprit identification in multi-hop pipelines; fair slashing | **Do now (simplified form)** |
|
||||||
|
| Seed-synchronized sampling (Token-DiFR-style exact-token audits) | **Medium** — requires controlling the sampler (Gumbel-max w/ shared seed) in node runtime | Zero at serve time | Strongest per-token evidence; >98% exact match for honest nodes | **Do if node runtime is ours; else logprob-rank tolerance** |
|
||||||
|
| Statistical logprob/perplexity monitoring (passive, on all traffic) | **High** — reference-free heuristics (output perplexity, repetition, truncation) + periodic logprob tests | Tiny | Cheap tripwire that raises a node's audit rate when outputs look off | **Do now (cheap complement)** |
|
||||||
|
| Reputation-weighted routing + reputation-weighted audit rate with floor | **High** — extends existing tracker reputation/strikes | None | Makes deterrence math work; concentrates audits on new/suspect nodes | **Do now** |
|
||||||
|
| Full interactive verification game (Truebit/Verde) with bitwise RepOps kernels | Low — requires replacing node operator stacks, decentralized referees | High engineering; runtime penalty for reproducible kernels | Trustless dispute resolution without a trusted tracker | **Roadmap (multi-tracker era)** |
|
||||||
|
| zkML proofs of inference | None — GPT-2-scale, tens of seconds per proof, server-class hardware ([zkGPT](https://www.usenix.org/conference/usenixsecurity25/presentation/qu-zkgpt); [NanoZK](https://arxiv.org/html/2603.18046v1)) | 100–10,000× | Cryptographic soundness | **Roadmap-only; re-evaluate yearly** |
|
||||||
|
| GPU TEE attestation | None on consumer cards (H100/Blackwell + SEV-SNP/TDX servers only — [NVIDIA guide](https://docs.nvidia.com/cc-deployment-guide-tdx-snp.pdf)) | ~2–10% where available | Hardware-rooted "right model+code ran" | **Roadmap: optional attested tier for datacenter contributors** |
|
||||||
|
| PoW-style hardware challenges (io.net-like) | Medium — trivial to add | Idle-time only | Sybil/VRAM/capacity attestation at registration; NOT output correctness | **Optional, registration-time only; don't confuse with fraud layer** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Recommended layered scheme for alpha
|
||||||
|
|
||||||
|
1. **Commit layer (on-demand, audit-selected):** when a request is selected for audit, each hop signs and reports a TOPLOC-style top-k fingerprint of its output boundary activations plus the sampling metadata (params, seed if controlled). Client-visible response carries the full claimed token sequence. Commitments are cheap and make retroactive lying impossible — they are *audit pins, not proofs* (§4).[^alpha-on-demand]
|
||||||
|
|
||||||
|
[^alpha-on-demand]: Alpha implements **on-demand** commitments per [ADR-0018 §3](../../docs/adr/0018-fraud-detection-verification-and-reputation.md) — not every request. Nodes retain recent activations briefly; serving path stays uncommitted until audit selection.
|
||||||
|
2. **Audit layer (~5% of requests, VRF/tracker-RNG selected, indistinguishable ex ante):** a reference executor teacher-forces the claimed tokens in one prefill and checks (a) per-position token plausibility under the reference distribution (exact match if seed-synced; logprob-rank tolerance otherwise) and (b) hop-boundary fingerprints within TOPLOC-style exponent/mantissa tolerances. New/low-reputation nodes get 20–30% audit rates, veterans 2–3%, floor ≥ 2%, budget-balanced to ≈5% overall.
|
||||||
|
3. **Blame layer (on audit failure):** replay comparison across hop cut-points identifies the first divergent hop; that node eats the penalty (forfeit pending balance + strike, per the existing forfeiture design), and its audit rate snaps to maximum. Honest-noise false positives are handled by tolerance calibration against an honest-node baseline corpus (collect this first — run identical jobs across the current node fleet to measure the honest divergence envelope before setting thresholds).
|
||||||
|
4. **Reputation layer:** reputation derives exclusively from audit outcomes + uptime/latency, never peer ratings (no collusion surface). It weights routing (earnings) and inversely weights audit probability. Slow build, instant loss, decay on inactivity, entry probation for Sybil/whitewash resistance.
|
||||||
|
5. **Tripwire layer (all traffic, passive):** perplexity/repetition/truncation anomaly scoring on outputs; anomalies don't punish directly, they escalate the node's audit rate.
|
||||||
|
|
||||||
|
This is, deliberately, the Prime Intellect / Hyperbolic pattern adapted to multi-hop pipelines with the tracker as referee — every component has a deployed or peer-reviewed precedent.
|
||||||
|
|
||||||
|
## 9. Explicitly NOT feasible for alpha (roadmap-only)
|
||||||
|
|
||||||
|
- **zkML proofs of LLM inference** — 3+ orders of magnitude too slow for multi-billion-parameter serving; provers won't run on 8 GB cards. Revisit if layerwise/GKR provers (NanoZK, zkGPT lineage) reach real-time on 7B+ models.
|
||||||
|
- **GPU TEE / confidential-computing attestation on volunteer nodes** — hardware doesn't exist on consumer GPUs. Keep as an *attested datacenter tier* concept.
|
||||||
|
- **Fully trustless interactive verification games** (Truebit/Verde with on-chain referees and bitwise-reproducible kernels) — pointless while a single trusted tracker exists; becomes the correct escalation path when trackers decentralize. Requires shipping a reproducible-operator runtime to nodes.
|
||||||
|
- **Decentralized third-party verifier markets** (and their incentive pathologies: verifier's dilemma, forced-error jackpots, Truebit's 500–5000% verification tax) — single-tracker alpha sidesteps all of it; re-enters with multi-tracker.
|
||||||
|
- **Peer-rating reputation (EigenTrust-style transitive trust)** — collusion/Sybil surface with no benefit while the tracker is the sole verifier.
|
||||||
|
- **Commit-reveal timelock machinery for evaluator honesty** (Bittensor CRV3) — solves weight-copying among many validators; irrelevant until independent validators exist.
|
||||||
|
- **PoW puzzles as a correctness mechanism** — category error (§4); acceptable only as optional registration-time hardware attestation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
**Papers / peer-reviewed:**
|
||||||
|
- Truebit: [A scalable verification solution for blockchains](http://people.cs.uchicago.edu/~teutsch/papers/truebit.pdf) (Teutsch & Reitwießner)
|
||||||
|
- Gensyn Verde: [arXiv:2502.19405](https://arxiv.org/html/2502.19405v1); [Gensyn blog](https://blog.gensyn.ai/verde-a-verification-system-for-machine-learning-over-untrusted-nodes/); [Gensyn docs](https://docs.gensyn.ai/core-components)
|
||||||
|
- TOPLOC: [arXiv:2501.16007](https://arxiv.org/html/2501.16007v1) (ICML 2025); [GitHub](https://github.com/PrimeIntellect-ai/toploc)
|
||||||
|
- INTELLECT-2: [arXiv:2505.07291](https://arxiv.org/html/2505.07291)
|
||||||
|
- Proof of Sampling (Hyperbolic): [arXiv:2405.00295](https://arxiv.org/html/2405.00295); [spML blog](https://www.hyperbolic.ai/blog/spml-breakdown)
|
||||||
|
- DiFR (Token/Activation): [arXiv:2511.20621](https://arxiv.org/pdf/2511.20621); [explainer](https://technicallyprivate.substack.com/p/token-difr-llm-inference-verification)
|
||||||
|
- Logprob tracking: [arXiv:2512.03816](https://arxiv.org/html/2512.03816v1); Model-substitution auditing: [arXiv:2504.04715](https://arxiv.org/html/2504.04715v1)
|
||||||
|
- VeriLLM: [arXiv:2509.24257](https://arxiv.org/html/2509.24257v3); Optimistic TEE-Rollups: [arXiv:2512.20176](https://doi.org/10.48550/arxiv.2512.20176)
|
||||||
|
- zkGPT: [USENIX Security 2025](https://www.usenix.org/conference/usenixsecurity25/presentation/qu-zkgpt); NanoZK: [arXiv:2603.18046](https://arxiv.org/html/2603.18046v1); Modulus "Cost of Intelligence": [IACR 2026/1063](https://eprint.iacr.org/2026/1063)
|
||||||
|
- Nondeterminism: [Thinking Machines blog](https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/); [arXiv:2506.09501](https://arxiv.org/html/2506.09501v2); [arXiv:2511.17826](https://arxiv.org/html/2511.17826v2); [arXiv:2511.00025](https://arxiv.org/html/2511.00025v1)
|
||||||
|
- Layer pruning: [arXiv:2403.17887](https://arxiv.org/html/2403.17887v1) (Gromov et al., ICLR 2025); [ShortGPT, arXiv:2403.03853](https://arxiv.org/pdf/2403.03853) (ACL 2025); LayerDrop: [arXiv:1909.11556](https://arxiv.org/abs/1909.11556)
|
||||||
|
- PoUW: [SoK, IACR 2025/1814](https://eprint.iacr.org/2025/1814.pdf); [arXiv:2209.03865](https://doi.org/10.48550/arxiv.2209.03865); [Wikipedia: Proof of work](https://en.wikipedia.org/wiki/Proof_of_work)
|
||||||
|
- EigenTrust: [WWW'03](https://nlp.stanford.edu/pubs/eigentrust.pdf); [EigenTrust++](https://doi.org/10.4108/icst.collaboratecom.2012.250420); [P2P reputation attack survey](https://cnitarot.github.io/papers/p2p-reputation-survey.pdf)
|
||||||
|
- Petals: [GitHub](http://github.com/bigscience-workshop/petals); [petals.dev](https://petals.dev/); [dev statements on incentives/correctness, HN](https://news.ycombinator.com/item?id=34215665)
|
||||||
|
|
||||||
|
**Official docs / engineering blogs:**
|
||||||
|
- NVIDIA CC: [H100 CC blog](https://developer.nvidia.com/blog/confidential-computing-on-h100-gpus-for-secure-and-trustworthy-ai/); [deployment guide (TDX/SNP)](https://docs.nvidia.com/cc-deployment-guide-tdx-snp.pdf); [CACM article](https://cacm.acm.org/practice/creating-the-first-confidential-gpus/); [Phala GPU-TEE on OpenRouter](https://phala.com/posts/GPU-TEEs-is-Alive-on-OpenRouter)
|
||||||
|
- Bittensor: [weight copying](https://docs.learnbittensor.org/concepts/weight-copying-in-bittensor); [commit reveal](https://docs.learnbittensor.org/concepts/commit-reveal); [OTF blog](https://blog.bittensor.com/weight-copying-in-bittensor-422585ab8fa5)
|
||||||
|
- io.net: [PoW docs](https://io.net/docs/guides/workers/proof-of-work); [Messari overview](https://messari.io/report/understanding-io-net-a-comprehensive-overview); [2026 risk analysis incl. Sybil attack history](https://cryptoaianalysis.com/io-net-io-analysis-2026/)
|
||||||
|
- Ritual: [ritual.net blog](https://ritual.net/blog/celestia); [ChainLight security review of AI-chain projects (Ritual, Akash)](https://blog.chainlight.io/ecosystem-explorer-exploring-security-risks-in-ai-blockchain-projects-2c490a726d13)
|
||||||
|
- Akash: [provider docs](https://akash.network/docs/providers/getting-started/should-i-run-a-provider/)
|
||||||
|
- Landscape survey: [Equilibrium Labs — State of Verifiable Inference](https://equilibrium.co/writing/state-of-verifiable-inference)
|
||||||
|
- Modulus Labs acquisition: [world.org announcement](https://world.org/blog/announcements/modulus-labs-joins-tfh-support-applied-research-world)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open-source reusability & build-vs-adopt
|
||||||
|
|
||||||
|
**Added 2026-07-04.** Focused follow-up: which of the projects above ship code we can actually reuse, and is verified decentralized inference a "solved problem"? All repo facts below checked against the GitHub repos/APIs on 2026-07-04.
|
||||||
|
|
||||||
|
### TOPLOC (Prime Intellect) — a real, adoptable library
|
||||||
|
|
||||||
|
- **Repo & license:** [PrimeIntellect-ai/toploc](https://github.com/PrimeIntellect-ai/toploc), **MIT** (both the repo license and the `pyproject.toml` declaration). Published on PyPI as `pip install toploc`. Separate [toploc-experiments](https://github.com/PrimeIntellect-ai/toploc-experiments) repo holds the paper's vLLM-integrated experiment code.
|
||||||
|
- **Maintenance:** actively maintained — created 2025-01-28, last push **2026-07-02** (two days before this writing), 56 stars / 12 forks / 12 open issues. Small single-purpose codebase (Python + a compiled extension; ships wheels, depends only on `torch`/`numpy`).
|
||||||
|
- **What it actually is:** a *library*, not a framework. Two function families ([README](https://github.com/PrimeIntellect-ai/toploc)):
|
||||||
|
- `build_proofs_bytes/base64(activations, decode_batching_size, topk, skip_prefill)` — prover side: takes a list of activation tensors (prefill tensor + one last-hidden-state tensor per generated token, bf16 in the examples) and returns compact proofs (~10 bytes each; 258 bytes per 32 tokens per the paper).
|
||||||
|
- `verify_proofs_bytes/base64(recomputed_activations, proofs, ...)` — verifier side: returns per-chunk `VerificationResult(exp_intersections, mant_err_mean, mant_err_median)`; **the accept/reject thresholds are ours to set and calibrate** (the paper gives reference thresholds validated across GPU types).
|
||||||
|
- **Assumptions:** model- and runtime-agnostic — it operates on tensors you extract yourself. It does *not* hook into vLLM/transformers for you; capturing the last hidden state per decode step in the node runtime, and recomputing activations via teacher-forced prefill on the validator, is our integration work. Detects precision changes by design, so **we must pin one canonical precision/quantization per served model** or honest quantized volunteers will fail verification.
|
||||||
|
- **Integration estimate for our pipeline-parallel case:** modest. (1) Node runtime: capture final-hidden-state per token, call `build_proofs_*`, attach to response — days, not weeks. (2) Tracker/validator: teacher-forced prefill re-run + `verify_proofs_*` — days. (3) The genuinely new part is *per-hop* commitments: TOPLOC as published commits only the **final** hidden state (whole-pipeline check, catches that fraud happened); applying the same encoding to hop-boundary activations for per-node blame is our own straightforward extension since the functions are tensor-generic, but its thresholds across heterogeneous 8 GB cards must be calibrated by us — that empirical honest-noise calibration is the real work. (4) Threshold calibration corpus across the volunteer fleet — the long pole, but required for any tolerance-based scheme regardless of library.
|
||||||
|
|
||||||
|
### Gensyn — papers and proprietary binaries, not a reusable verification library
|
||||||
|
|
||||||
|
What is actually public in [github.com/gensyn-ai](https://github.com/gensyn-ai) (org listing checked 2026-07-04):
|
||||||
|
|
||||||
|
- **[rl-swarm](https://github.com/gensyn-ai/rl-swarm)** — MIT, 1.7k stars: a framework for *RL training swarms*, plus [rl-swarm-contracts](https://github.com/gensyn-ai/rl-swarm-contracts) (coordination contracts). Not inference verification; not relevant to our fraud layer.
|
||||||
|
- **Verde (dispute resolution): paper only.** No `verde` repository exists in the org. The bisection/arbitration protocol from [arXiv:2502.19405](https://arxiv.org/html/2502.19405v1) has no published implementation. If we want it, we implement it from the paper.
|
||||||
|
- **RepOps / reproducible execution: shipping, but proprietary.** [repops-demo](https://github.com/gensyn-ai/repops-demo) is demo scripts with **no OSS license file** (only a `LICENSE-LLAMA` for the model weights) driving a prebuilt Docker image; supported targets include consumer RTX 3090/4070/4090 (CC 7.5–9, CUDA 12.6+). The successor [ree](https://github.com/gensyn-ai/ree) (Reproducible Execution Environment) is explicit in its README: the SDK wrapper is MIT, but **"the REE compiler binary and the REE reproducible-operators binary … are not licensed under the MIT License"** — they fall under a proprietary REE Binary License. REE does reproducible *LLM inference* with run/verify receipts, pipeline parallelism up to 72B (v0.2.0 notes), and needs Docker + NVIDIA driver ≥570. So bitwise cross-GPU determinism is real and demonstrably achievable on consumer GPUs — but as a closed-source appliance, not an embeddable library.
|
||||||
|
- **"Judge"** (verifiable AI evaluator, [docs](https://docs.gensyn.ai/core-components)) — no public library repo found in the org.
|
||||||
|
- **Assessment of the RepOps requirement:** deterministic cross-GPU execution means reimplementing/fixing the reduction order of every operator (matmul, attention, norms) for every hardware target — a compiler/kernel-engineering effort Gensyn keeps proprietary, and Thinking Machines' open batch-invariant kernels ([blog](https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/)) only address batch-invariance on one platform, not cross-GPU bitwise equality. **This is a large lift with no off-the-shelf OSS solution; it is exactly what TOPLOC-style tolerance matching lets us avoid.** Adopting REE would mean forcing volunteers into Gensyn's Docker appliance (proprietary binaries, driver floor, perf overhead) — a non-starter for our alpha; a possible future option for the *validator/referee* environment only.
|
||||||
|
|
||||||
|
### Bittensor — an incentive market, not verification code (confirmed)
|
||||||
|
|
||||||
|
- **Confirmed from primary sources:** the [whitepaper](https://bittensor.com/whitepaper) frames Bittensor as peers ranking peers ("intelligence measured by intelligence"), and the [Yuma Consensus docs](https://docs.learnbittensor.org/yuma-consensus) describe the on-chain mechanism precisely: each subnet validator submits a **weight vector ranking miners**; YC resolves the stake-weighted matrix into emissions, **clipping** any weight above the level supported by κ (default 0.5) of stake to punish out-of-consensus/collusive over-evaluation, and paying validators via EMA **bonds** that reward staying near consensus. There is **no cryptographic verification of any computation anywhere in the mechanism** — correctness of miner outputs is whatever each subnet's own validator code decides to score. The chain ([opentensor/subtensor](https://github.com/opentensor/subtensor), Rust, The Unlicense, actively developed) implements the consensus/emissions math, not inference checking.
|
||||||
|
- **What subnets actually do:** subnet owners write off-chain validator logic (Python, typically on the MIT-licensed [bittensor SDK](https://github.com/opentensor/bittensor)) that queries miners with tasks and scores responses — reference-model comparison, heuristics, or LLM-judging, entirely subnet-specific. Quality of validation is therefore uneven by construction.
|
||||||
|
- **Known weaknesses & mitigations (documented by Bittensor itself):** validator **weight-copying** (free-riding on the public weight matrix) — mitigated by Commit-Reveal v3 timelock-encrypted weights revealed epochs later ([weight copying](https://docs.learnbittensor.org/concepts/weight-copying-in-bittensor); [commit reveal](https://docs.learnbittensor.org/concepts/commit-reveal)); lazy/colluding validators — mitigated only economically via clipping and bond penalties; and the docs concede commit-reveal fails if miner rankings are too static.
|
||||||
|
- **Reusable for us:** the *design patterns* — stake-weighted score aggregation with clipping, EMA-smoothed trust, commit-reveal for future multi-validator honesty — not code. Subtensor's Rust consensus math is chain-embedded and solves a multi-validator problem we don't have while the single tracker is the only scorer.
|
||||||
|
|
||||||
|
### Other OSS in this niche
|
||||||
|
|
||||||
|
- **EZKL** ([zkonduit/ezkl](https://github.com/zkonduit/ezkl)) — **real, mature library** (Rust with Python/JS bindings, **Apache-2.0** per its [npm package](https://registry.npmjs.org/ezkl) and README, ~1.2k stars, last push 2026-02). It proves ONNX-graph inference in ZK and genuinely works — for *small* models (MLPs, small CNNs/regressors; §1.3 overheads apply). Irrelevant to serving multi-billion-parameter LLMs, but adoptable later if we ever need to prove a tiny model (e.g., a routing/scoring model) on-chain.
|
||||||
|
- **Petals** ([bigscience-workshop/petals](https://github.com/bigscience-workshop/petals)) — MIT, 10.3k stars, but **effectively dormant (last push Sep 2024)** and contains **zero trust machinery**: its own docs say malicious peers can alter outputs and recommend private swarms (§5). Its `hivemind` DHT/networking stack (MIT, also mirrored by Gensyn) is reusable *infrastructure*, not verification. Petals is validation of our architecture and a warning, not a component.
|
||||||
|
- **Hyperbolic PoSP/spML** — **paper + marketing only**. The [HyperbolicLabs GitHub org](https://github.com/HyperbolicLabs) contains agent kits, MCP servers, and a k8s OS — no PoSP/spML implementation is published. Adapt the pattern from [arXiv:2405.00295](https://arxiv.org/html/2405.00295); there is nothing to adopt.
|
||||||
|
- **Ritual Infernet** — the node ([ritual-net/infernet-node](https://github.com/ritual-net/infernet-node), Python, BSD-3-Clause-Clear per its community port; direct repo access was flaky at check time) plus [infernet-deploy](https://github.com/ritual-net/infernet-deploy)/[infernet-sdk](https://github.com/ritual-net/infernet-sdk) are open **coordination/oracle plumbing** (request routing, container orchestration, on-chain delivery). Proofs are a pluggable slot, not shipped verification logic, and ChainLight's review found no output aggregation, reputation, or penalties in the base system (§5). Nothing here advances our fraud layer.
|
||||||
|
- **DiFR** — the Token/Activation-DiFR authors state they released an **open-source vLLM integration** ([arXiv:2511.20621](https://arxiv.org/pdf/2511.20621)); worth tracking as a second adoptable audit primitive alongside TOPLOC, especially the seed-synchronized exact-token variant.
|
||||||
|
|
||||||
|
### Bottom line: is it solved?
|
||||||
|
|
||||||
|
**The audit primitive is solved and adoptable; the system around it is not.** No OSS project ships an end-to-end "verified decentralized inference network in a box" — every network either built theirs in-house (Prime Intellect), kept the hard part proprietary (Gensyn REE binaries), or doesn't verify at all (Petals, Akash, base Infernet, Bittensor's chain). What *is* genuinely reusable is small, high-quality, and MIT-licensed: TOPLOC. Everything else we need is a pattern to adapt or thin logic to build on our tracker, which is appropriately bespoke (it's our economics).
|
||||||
|
|
||||||
|
| Capability | Verdict | Component / reference | Rationale |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Detection / audit** (teacher-forced re-verification) | **ADOPT** | [`toploc`](https://github.com/PrimeIntellect-ai/toploc) (MIT, PyPI, maintained) — track [DiFR's vLLM integration](https://arxiv.org/pdf/2511.20621) as a complement | Proven encoding + verifier with cross-GPU tolerance semantics; our work is runtime hooks + threshold calibration, not algorithm development |
|
||||||
|
| **Blame attribution / bisection** (per-hop) | **ADAPT + BUILD** | Verde's commit-then-recompute pattern ([arXiv:2502.19405](https://arxiv.org/html/2502.19405v1)); reuse TOPLOC's encoding on hop-boundary activations | No published Verde code; full bisection is overkill for a handful of hops — a single referee replay over committed hop boundaries suffices (§1.2) |
|
||||||
|
| **Reputation / incentive** | **ADAPT + BUILD** | PoSP deterrence math ([arXiv:2405.00295](https://arxiv.org/html/2405.00295)); Yuma patterns (clipping, EMA trust, [commit-reveal](https://docs.learnbittensor.org/concepts/commit-reveal)) for the future multi-validator era; EigenTrust pitfalls list (§6) | No code to adopt (PoSP unpublished; Yuma is chain-embedded Rust for a different topology); our tracker-side logic is small and economics-specific |
|
||||||
|
| **Cryptographic proof** (zkML / TEE / bitwise-reproducible execution) | **NEITHER (roadmap)** | EZKL (Apache-2.0) if a tiny provable model ever needs it; Gensyn REE as a proprietary-appliance option for a future referee tier; GPU TEE per §1.4 | All three are unusable on 8 GB volunteer cards today (§1.3–1.4); REE's reproducible operators are closed-source binaries |
|
||||||
|
|
||||||
|
**Direct answer to the owner:** Gensyn and Bittensor are the right things to read but the wrong things to build on — Gensyn's verification core is a paper plus proprietary binaries aimed at training-grade trustlessness we don't need under a single trusted tracker, and Bittensor ships an incentive market that deliberately contains no computation verification at all. The one shelf-ready piece for our exact audit step is TOPLOC (MIT, pip-installable, updated this week), and the recommended alpha scheme in §8 remains: adopt TOPLOC for the audit primitive, adapt PoSP/Verde patterns for deterrence and blame, and build the thin tracker-side reputation/audit-rate logic ourselves.
|
||||||
88
.scratch/alpha-hardening/runbooks/01-ledger-backup.md
Normal file
88
.scratch/alpha-hardening/runbooks/01-ledger-backup.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
Status: stub
|
||||||
|
|
||||||
|
# Runbook: Ledger backup
|
||||||
|
|
||||||
|
Covers backing up the tracker's authoritative money/trust state — the billing
|
||||||
|
ledger, dashboard accounts DB, and node registry (strike/ban/reputation) — and
|
||||||
|
how to pause hive gossip during the backup window so peers don't replicate
|
||||||
|
against a half-copied file.
|
||||||
|
|
||||||
|
## Trust assumptions (read first)
|
||||||
|
|
||||||
|
Per [ADR-0016](../../../docs/adr/0016-alpha-scope-and-known-limitations.md), one
|
||||||
|
operator-designated tracker holds the treasury keypair and is the source of
|
||||||
|
truth for settlement; other hive members only replicate. Back up **that**
|
||||||
|
tracker's databases — a follower's copies are eventually consistent, not
|
||||||
|
authoritative. See [ADR-0015](../../../docs/adr/0015-usdt-custodial-settlement.md)
|
||||||
|
for the settlement loop these tables feed.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Shell access to the settlement-capable tracker host.
|
||||||
|
- `sqlite3` CLI (or `.backup` support in the Python `sqlite3` module) available
|
||||||
|
for online, consistent snapshots.
|
||||||
|
- Know the tracker's configured DB paths — defaults, unless overridden by CLI
|
||||||
|
flags:
|
||||||
|
- Billing ledger: `billing.sqlite` (`--billing-db`, `DEFAULT_BILLING_DB_PATH`
|
||||||
|
in `packages/tracker/meshnet_tracker/billing.py`)
|
||||||
|
- Dashboard accounts: `accounts.sqlite` (`--accounts-db`,
|
||||||
|
`DEFAULT_ACCOUNTS_DB_PATH` in `packages/tracker/meshnet_tracker/accounts.py`)
|
||||||
|
- Node registry (strike/ban/reputation event log,
|
||||||
|
`packages/contracts/meshnet_contracts/__init__.py::RegistryEventLog`): path
|
||||||
|
is whatever was passed as `registry_db` when the tracker's
|
||||||
|
`LocalSolanaContracts` was constructed. **As of this writing the tracker
|
||||||
|
CLI (`meshnet_tracker/cli.py`) does not expose a `--registry-db` flag or
|
||||||
|
wire a `contracts=` instance into `TrackerServer` by default** — confirm
|
||||||
|
with whoever deployed this tracker whether registry persistence is
|
||||||
|
actually enabled before assuming a file exists to back up. If it isn't
|
||||||
|
wired up yet, strike/ban/reputation state is RAM-only and this step is
|
||||||
|
moot until that gap closes (tracked loosely against issue 05).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Identify the actual DB paths in use (check the tracker's start command /
|
||||||
|
systemd unit / process env for `--billing-db`, `--accounts-db`, and any
|
||||||
|
registry DB argument).
|
||||||
|
2. **Pause hive gossip** on this tracker so peers don't pull a partial/locked
|
||||||
|
file mid-backup:
|
||||||
|
- If the tracker is the sole settlement node with no `--cluster-peers`,
|
||||||
|
gossip is already off — skip to step 3.
|
||||||
|
- Otherwise, stop replication by restarting the process without
|
||||||
|
`--cluster-peers` (or with an empty peer list) for the duration of the
|
||||||
|
backup, or take the backup during a maintenance window with peers
|
||||||
|
temporarily pointed away from this tracker at the load balancer/DNS
|
||||||
|
level. There is currently no live "pause gossip" admin endpoint — this is
|
||||||
|
a process-restart-level operation.
|
||||||
|
- Confirm no in-flight `/v1/registry/gossip`, `/v1/billing/gossip`, or
|
||||||
|
`/v1/accounts/gossip` traffic before proceeding (check access logs).
|
||||||
|
3. Take an online, consistent copy of each SQLite file using the backup API
|
||||||
|
rather than `cp` (WAL-mode files can be mid-write):
|
||||||
|
```
|
||||||
|
sqlite3 billing.sqlite ".backup '/backups/billing-$(date +%Y%m%dT%H%M%S).sqlite'"
|
||||||
|
sqlite3 accounts.sqlite ".backup '/backups/accounts-$(date +%Y%m%dT%H%M%S).sqlite'"
|
||||||
|
# registry DB, if configured:
|
||||||
|
sqlite3 registry.sqlite ".backup '/backups/registry-$(date +%Y%m%dT%H%M%S).sqlite'"
|
||||||
|
```
|
||||||
|
4. Verify each backup opens and has rows in its expected tables
|
||||||
|
(`billing_ledger`/event log tables, `accounts`, `registry_events`).
|
||||||
|
5. Resume gossip (restore `--cluster-peers` / routing) once backups are
|
||||||
|
confirmed good.
|
||||||
|
6. Ship backups off-host per your normal retention policy. Do not store them
|
||||||
|
alongside `.env.devnet` or keypair files (see secrets handling below).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- If a restore is needed, stop the tracker, replace the live `.sqlite` file(s)
|
||||||
|
with the chosen backup, and restart. Because billing/accounts/registry each
|
||||||
|
use append-only event logs, a stale restore under-counts recent activity
|
||||||
|
rather than corrupting state — reconcile any gap against node/operator
|
||||||
|
reports for the missing window before resuming payouts.
|
||||||
|
- If gossip was paused via a peer-list restart, confirm peers re-sync
|
||||||
|
(`events_since` catch-up) before considering the rollback complete.
|
||||||
|
|
||||||
|
## Secrets handling
|
||||||
|
|
||||||
|
- Never commit `.env.devnet`, treasury keypair JSON files, `--hive-secret`, or
|
||||||
|
`--validator-service-token` values to a repo or ship them inside a DB backup
|
||||||
|
archive. Back these up separately, encrypted, per your existing secrets
|
||||||
|
process.
|
||||||
112
.scratch/alpha-hardening/runbooks/02-treasury-key-rotation.md
Normal file
112
.scratch/alpha-hardening/runbooks/02-treasury-key-rotation.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
Status: stub
|
||||||
|
|
||||||
|
# Runbook: Treasury key rotation (devnet mock-USDT)
|
||||||
|
|
||||||
|
Covers rotating the devnet treasury keypair and/or the mock-USDT mint without
|
||||||
|
double-crediting client ledger balances or double-paying nodes.
|
||||||
|
|
||||||
|
## Trust assumptions (read first)
|
||||||
|
|
||||||
|
Per [ADR-0015](../../../docs/adr/0015-usdt-custodial-settlement.md), a single
|
||||||
|
project-owned wallet custodies all funds; the treasury keypair is loaded only
|
||||||
|
on the operator-designated settlement tracker (ADR-0016 §1). Rotating this key
|
||||||
|
is a trusted-operator action — there is no on-chain multisig or trustless
|
||||||
|
handoff in the alpha design. Devnet uses a self-created mock-USDT SPL mint
|
||||||
|
(6 decimals); real USDT only exists on mainnet, so this procedure is
|
||||||
|
devnet-only until a mainnet cutover ADR supersedes it.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Access to `scripts/devnet_setup.py` and its dependencies (`solders`,
|
||||||
|
`meshnet_contracts.solana_adapter.SolanaCustodialTreasury`).
|
||||||
|
- The current treasury keypair path (default
|
||||||
|
`~/.config/solana/meshnet-treasury.json`, or whatever `--treasury-keypair`
|
||||||
|
the running tracker uses) and current `MESHNET_USDT_MINT` /
|
||||||
|
`MESHNET_TREASURY_WALLET` values (see `.env.devnet`, never committed).
|
||||||
|
- Ability to stop/restart the settlement-capable tracker.
|
||||||
|
- Confirm the deposit watcher's dedupe state (transaction signatures already
|
||||||
|
credited) is durable — it must survive the rotation so replayed/rescanned
|
||||||
|
transfers under the *old* wallet don't get re-credited under the *new* one.
|
||||||
|
|
||||||
|
## Two rotation scenarios
|
||||||
|
|
||||||
|
### A. Rotate the treasury keypair only (same mint, same on-chain wallet funds move)
|
||||||
|
|
||||||
|
The treasury wallet address changes because it's derived from the keypair, so
|
||||||
|
this requires migrating funds, not just swapping a file.
|
||||||
|
|
||||||
|
1. Generate a new keypair (do **not** reuse `_load_or_create_keypair` against
|
||||||
|
the old path — write to a new path so both keys exist during the
|
||||||
|
transition):
|
||||||
|
```
|
||||||
|
python scripts/devnet_setup.py --keypair ~/.config/solana/meshnet-treasury-new.json \
|
||||||
|
--mint <EXISTING_MOCK_USDT_MINT> --env-out .env.devnet.new
|
||||||
|
```
|
||||||
|
This creates the new treasury wallet + token account and reuses the
|
||||||
|
existing mint (no new token, so client balances denominated in that mint
|
||||||
|
are unaffected).
|
||||||
|
2. Drain the old treasury token account to the new one via a single SPL
|
||||||
|
transfer sized to the *entire current balance* (record the exact amount
|
||||||
|
and the source tx signature before moving anything).
|
||||||
|
3. **Freeze settlement during the drain**: stop the settlement-capable
|
||||||
|
tracker (or restart it with no `--treasury-keypair` so the settlement loop
|
||||||
|
is inert) before step 2, so no payout is in flight against the old wallet
|
||||||
|
while funds move.
|
||||||
|
4. Update the tracker's `--treasury-keypair`, `--treasury-wallet`-derived
|
||||||
|
config (i.e. the new `.env.devnet`) and restart the tracker pointed at the
|
||||||
|
new keypair.
|
||||||
|
5. Verify: `treasury.get_sol_balance()` / mock-USDT balance on the new wallet
|
||||||
|
matches the old wallet's pre-drain balance; old wallet balance is zero.
|
||||||
|
6. Only after verification, revoke/delete the old keypair file.
|
||||||
|
|
||||||
|
### B. Rotate the mock-USDT mint (e.g. compromised or mis-configured mint)
|
||||||
|
|
||||||
|
This is a bigger change — it invalidates every client's existing off-chain
|
||||||
|
ledger balance denomination reference and any node's pending on-chain payout
|
||||||
|
expectations. Treat as a deliberate migration, not a routine rotation:
|
||||||
|
|
||||||
|
1. Settle (pay out) all pending node balances against the *old* mint before
|
||||||
|
cutover — the pending-balance forfeiture/collateral model (ADR-0015)
|
||||||
|
assumes pending balances are payable in a known mint.
|
||||||
|
2. Create the new mint and treasury token account:
|
||||||
|
```
|
||||||
|
python scripts/devnet_setup.py --keypair <treasury-keypair> --env-out .env.devnet
|
||||||
|
```
|
||||||
|
(omit `--mint` so a fresh mint is created).
|
||||||
|
3. Update tracker config (`MESHNET_USDT_MINT`) and restart.
|
||||||
|
4. Re-mint/airdrop mock USDT to active client wallets under the new mint as
|
||||||
|
needed (`--mint-to`), since off-chain ledger balances are *not*
|
||||||
|
automatically re-denominated — this is a devnet convenience step, not a
|
||||||
|
guarantee that would hold for real USDT.
|
||||||
|
|
||||||
|
## Avoiding double-credit
|
||||||
|
|
||||||
|
The deposit watcher (issue 32) dedupes by on-chain transaction signature. The
|
||||||
|
signature space for the old and new treasury token accounts/mints is
|
||||||
|
disjoint, so:
|
||||||
|
|
||||||
|
- Do not replay old-wallet deposit history against the new wallet's watcher —
|
||||||
|
it has no record of those signatures and would (correctly) not credit them,
|
||||||
|
but any manual "catch-up crediting" script must not re-process transfers the
|
||||||
|
old watcher already credited. Cross-check the old ledger's credited-tx-sig
|
||||||
|
table before any manual reconciliation entry.
|
||||||
|
- Keep the old watcher's dedupe DB/table around (don't drop it as part of
|
||||||
|
rotation) until you've confirmed no in-flight deposits to the old address
|
||||||
|
remain unconfirmed.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Scenario A: if the new wallet fails verification, restart the tracker with
|
||||||
|
the old `--treasury-keypair` — no client-facing state changed since ledger
|
||||||
|
balances are keyed by API key, not treasury wallet address.
|
||||||
|
- Scenario B: if re-minting under the new mint goes wrong, restart the
|
||||||
|
tracker against the old `MESHNET_USDT_MINT` config; nothing was destroyed on
|
||||||
|
the old mint.
|
||||||
|
|
||||||
|
## Secrets handling
|
||||||
|
|
||||||
|
- Never commit `.env.devnet`, `.env.devnet.new`, or any `*treasury*.json`
|
||||||
|
keypair file. `scripts/devnet_setup.py` writes keypairs with `0o600`
|
||||||
|
permissions — preserve that when copying.
|
||||||
|
- Treat the treasury keypair as the single highest-value secret in this
|
||||||
|
system per ADR-0015/ADR-0016: anyone with it can drain custodial funds.
|
||||||
108
.scratch/alpha-hardening/runbooks/03-upgrade-path.md
Normal file
108
.scratch/alpha-hardening/runbooks/03-upgrade-path.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
Status: stub
|
||||||
|
|
||||||
|
# Runbook: Tracker upgrade path (rolling restart)
|
||||||
|
|
||||||
|
Covers restarting/upgrading tracker processes in a hive without losing
|
||||||
|
strike/ban/reputation state or interrupting settlement, per the ADR-0016 §4
|
||||||
|
guarantee that reputation carries forward across restarts.
|
||||||
|
|
||||||
|
## Trust assumptions (read first)
|
||||||
|
|
||||||
|
Per [ADR-0016](../../../docs/adr/0016-alpha-scope-and-known-limitations.md),
|
||||||
|
only one operator-designated tracker holds the treasury keypair and runs the
|
||||||
|
settlement loop ([ADR-0015](../../../docs/adr/0015-usdt-custodial-settlement.md));
|
||||||
|
other hive members replicate for routing only. Raft (`packages/tracker/meshnet_tracker/raft.py`)
|
||||||
|
elects a leader for shard-assignment/registration commands — settlement
|
||||||
|
leadership is a separate, operator-configured concept, not the Raft leader.
|
||||||
|
Plan restarts so the settlement tracker's downtime window is minimized
|
||||||
|
independent of routing-tracker restarts.
|
||||||
|
|
||||||
|
## Known gap — read before relying on this runbook
|
||||||
|
|
||||||
|
Strike/ban/reputation persistence itself was implemented in issue 05
|
||||||
|
(`packages/contracts/meshnet_contracts/__init__.py::RegistryEventLog`,
|
||||||
|
SQLite-backed, same pattern as billing/accounts). **As of this writing,
|
||||||
|
`packages/tracker/meshnet_tracker/cli.py` does not expose a `--registry-db`
|
||||||
|
flag, nor does it construct a `contracts=` instance to pass into
|
||||||
|
`TrackerServer`.** Running the tracker via the stock CLI entry point leaves
|
||||||
|
`server.contracts` as `None`, which means:
|
||||||
|
|
||||||
|
- Ban checks (`_registration_ban_error`), reputation-weighted routing
|
||||||
|
(`_reputation_multiplier`), and the `/v1/registry/wallets` endpoint are
|
||||||
|
inert.
|
||||||
|
- There is nothing to persist across restarts in that configuration — the
|
||||||
|
"survives restart" guarantee only holds for deployments that construct
|
||||||
|
`LocalSolanaContracts(registry_db=<path>)` and wire it into `TrackerServer(contracts=...)`
|
||||||
|
themselves (e.g. a custom entrypoint or embedding the server programmatically).
|
||||||
|
|
||||||
|
Before following the restart steps below, confirm which mode this deployment
|
||||||
|
runs in. If it's the stock CLI with no custom `contracts` wiring, strike/ban
|
||||||
|
state is RAM-only regardless of this runbook, and a restart resets it — treat
|
||||||
|
that as a pre-existing gap to flag to the owner, not something this runbook
|
||||||
|
can work around.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Confirm registry persistence is actually wired (see gap above) and note the
|
||||||
|
registry DB path in use.
|
||||||
|
- Confirm billing (`--billing-db`) and accounts (`--accounts-db`) persistence
|
||||||
|
paths — these already default to `billing.sqlite` / `accounts.sqlite` and
|
||||||
|
persist regardless of the registry gap.
|
||||||
|
- Know which tracker in the hive is currently the settlement leader (holds
|
||||||
|
`--treasury-keypair`) versus routing-only peers.
|
||||||
|
- `--hive-secret` / `MESHNET_HIVE_SECRET` configured identically across all
|
||||||
|
hive members (ADR-0017) — a mismatched secret on restart fails gossip
|
||||||
|
closed, not open.
|
||||||
|
- Take a [ledger backup](01-ledger-backup.md) before any upgrade that touches
|
||||||
|
schema or dependency versions.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Routing-only trackers first.** For each non-settlement tracker in the
|
||||||
|
hive:
|
||||||
|
a. Confirm it's not the current Raft leader (`GET /v1/raft/status`); if it
|
||||||
|
is, this restart forces a re-election — acceptable, but expect a brief
|
||||||
|
registration-proxy gap while a new leader is elected.
|
||||||
|
b. Stop the process, deploy the new code/config, restart with the same
|
||||||
|
`--billing-db` / `--accounts-db` / registry DB paths and the same
|
||||||
|
`--hive-secret` and `--cluster-peers`.
|
||||||
|
c. Check `/v1/raft/status` and `/v1/registry/wallets` (if registry is
|
||||||
|
wired) come back consistent with peers within one gossip interval.
|
||||||
|
d. Move to the next routing tracker only after this one rejoins cleanly.
|
||||||
|
2. **Settlement tracker last**, and only during a low-settlement-activity
|
||||||
|
window if possible:
|
||||||
|
a. Confirm no payout is mid-flight (check tracker logs / pending balance
|
||||||
|
levels against `--settle-period` / `--payout-threshold`).
|
||||||
|
b. Stop the process. The treasury keypair file itself is untouched by the
|
||||||
|
restart — do not regenerate it (see
|
||||||
|
[treasury key rotation](02-treasury-key-rotation.md) for that separate
|
||||||
|
procedure).
|
||||||
|
c. Deploy new code/config, restart with identical `--treasury-keypair`,
|
||||||
|
`--solana-rpc-url`, `--usdt-mint`, `--settle-period`,
|
||||||
|
`--payout-threshold`, `--payout-dust-floor`, and DB paths.
|
||||||
|
d. Verify strike/ban/reputation state (if wired) matches pre-restart values
|
||||||
|
via `/v1/registry/wallets`, and that billing/accounts ledgers show the
|
||||||
|
same balances as immediately before shutdown.
|
||||||
|
3. Confirm all hive members show each other as alive peers and gossip
|
||||||
|
(`/v1/registry/gossip`, `/v1/billing/gossip`, `/v1/accounts/gossip`) is
|
||||||
|
flowing without HMAC auth failures in logs (ADR-0017).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- Each tracker's on-disk SQLite files are untouched by a code-only upgrade;
|
||||||
|
rolling back means redeploying the previous binary/version against the same
|
||||||
|
DB paths. Because billing/accounts/registry are append-only event logs, a
|
||||||
|
version rollback does not lose data written by the newer version as long as
|
||||||
|
the schema didn't change — if the upgrade included a schema migration,
|
||||||
|
restore from the pre-upgrade [ledger backup](01-ledger-backup.md) instead.
|
||||||
|
- If a settlement-tracker restart leaves it unable to reach the treasury RPC
|
||||||
|
endpoint, routing-only trackers continue serving traffic — settlement simply
|
||||||
|
pauses until the leader recovers; no funds are at risk since payouts require
|
||||||
|
the loaded keypair.
|
||||||
|
|
||||||
|
## Secrets handling
|
||||||
|
|
||||||
|
- Never commit `.env.devnet`, `--hive-secret` / `MESHNET_HIVE_SECRET`,
|
||||||
|
`--validator-service-token`, or the treasury keypair file as part of a
|
||||||
|
deploy/config change. Deploy scripts should read these from the existing
|
||||||
|
secrets store, not from a file checked into the repo.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# ADR-0020: Distributed GGUF/llama.cpp Runtime With Per-Shard Local KV
|
||||||
|
|
||||||
|
Status: Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The project currently uses PyTorch/Transformers for real model shards. That decision was captured in ADR-0001 because llama.cpp RPC at the time required the primary node to load the full model and distribute weights to workers, which conflicted with the desired model where nodes independently hold shards.
|
||||||
|
|
||||||
|
We now want to serve very large open models, including GLM-5.2 and Ornith-class MoE models, over a torrent-like inference marketplace. CPU and mixed consumer hardware matter. LM Studio and llama.cpp demonstrate much better CPU/GGUF performance than our current PyTorch CPU path. The user also has a personal relationship with Georgi Gerganov, making upstream collaboration plausible.
|
||||||
|
|
||||||
|
The current distributed PyTorch path is not yet production-grade: it recomputes the full growing sequence for every output token and disables KV cache inside manual layer calls. It sends hidden activations across seams, not KV, but those activations currently cover the full sequence every decode step.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt a distributed GGUF/llama.cpp runtime track while keeping PyTorch as the reference and fast-architecture backend.
|
||||||
|
|
||||||
|
The runtime model is:
|
||||||
|
|
||||||
|
- GGUF/model artifacts are distributed through torrent/content-addressed storage.
|
||||||
|
- Nodes independently acquire and verify artifacts; no root node streams model weights to workers at session start.
|
||||||
|
- Tracker chooses a sticky route covering all layers.
|
||||||
|
- Each node owns hot KV/state for the layers it executes.
|
||||||
|
- Prefill sends chunked activations through the route and builds local per-shard KV.
|
||||||
|
- Decode sends one-step activations through the route and appends local KV at every shard.
|
||||||
|
- Cache/CDN servers store cold artifacts and optional prefix/session snapshots, not hot per-token KV.
|
||||||
|
- Context is capped at 128K for the first serious product path.
|
||||||
|
|
||||||
|
## Technical Framework
|
||||||
|
|
||||||
|
The design separates five planes:
|
||||||
|
|
||||||
|
- **Control plane**: tracker registry, coverage map, route selection, session lifecycle, telemetry, billing, and audit.
|
||||||
|
- **Artifact plane**: Shard Swarms, GGUF/safetensors/tokenizer files, manifests, hashes, and local node storage.
|
||||||
|
- **Execution plane**: active Inference Route, chunked prefill, one-step decode, and hidden-state movement across activation seams.
|
||||||
|
- **Session state plane**: per-shard Hot KV State on route nodes, plus optional Prefix Snapshots outside the hot loop.
|
||||||
|
- **Economics/trust plane**: reward accounting, validation events, slash proofs, public/private route policy.
|
||||||
|
|
||||||
|
Hard invariants:
|
||||||
|
|
||||||
|
1. Public-network Shards are contiguous layer ranges.
|
||||||
|
2. Hot KV State is local to the node serving that Shard in that Route Session.
|
||||||
|
3. Artifact distribution and route execution are separate systems.
|
||||||
|
4. Decode seam payload must be `O(hidden_size)`.
|
||||||
|
5. Prefill may be `O(sequence_length * hidden_size)`, but only in bounded chunks.
|
||||||
|
6. The tracker chooses routes; nodes do not negotiate route topology peer-to-peer.
|
||||||
|
7. Model/backend-specific cache internals stay behind backend capability reports.
|
||||||
|
8. PyTorch remains the correctness/reference backend while llama.cpp/GGUF becomes the performance backend.
|
||||||
|
9. Streaming responses are preferred when feasible; Generation Telemetry is always required.
|
||||||
|
|
||||||
|
The full challenge register is in [technical-challenges.md](./technical-challenges.md). The open decision gates are in [decision-framework.md](./decision-framework.md).
|
||||||
|
|
||||||
|
Resolved gate:
|
||||||
|
|
||||||
|
- Public-network Shards are layer ranges. Tensor-parallel/ring execution belongs inside a trusted node, colocated pod, or future composite node abstraction, not as the v1 public routing primitive.
|
||||||
|
- Hot KV State is local to each route node for the Shard it serves. Cache servers may store Prefix Snapshots, but they are not part of the per-token decode path.
|
||||||
|
- Distributed Route Session and Hot KV State semantics will be proven in the PyTorch route before llama.cpp/GGUF is extended for layer-boundary execution.
|
||||||
|
- Streaming responses are preferred when feasible. Realtime Generation Telemetry is required so clients can see phase, generated token count, and tokens/sec even during prefill or non-streaming fallback paths.
|
||||||
|
- llama.cpp/GGUF work targets upstreamable `libllama`/ggml hooks. A prototype fork is acceptable for exploration, but a permanent fork is not the plan.
|
||||||
|
- Model targeting is two-tiered: use a small llama.cpp-supported GGUF model for the first protocol smoke test, then use `deepseek-ai/DeepSeek-V4-Flash` as the first serious large-model target. GLM-5.2 and Ornith remain later support audits.
|
||||||
|
- Alpha fails Route Sessions on route-node loss instead of attempting automatic route repair. Repair requires compatible Prefix Snapshots and is a later capability.
|
||||||
|
- v1 activation transfer stays on binary HTTP as defined by ADR-0008. QUIC/WebRTC/custom transport can be introduced later behind the same activation protocol.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not put remote cache servers in the per-token hot KV path.
|
||||||
|
- Do not require every node to hold the full model.
|
||||||
|
- Do not fork llama.cpp long-term if upstream APIs can support the needed layer-boundary hooks.
|
||||||
|
- Do not target GLM-5.2 or Ornith first; prove the route/KV protocol on a simpler well-supported GGUF model, then target DeepSeek-V4-Flash as the first serious large model.
|
||||||
|
|
||||||
|
## Options Considered
|
||||||
|
|
||||||
|
### A. Keep PyTorch-only distributed inference
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
|
||||||
|
- Easy access to new Hugging Face architectures.
|
||||||
|
- Transformers has mature single-process KV semantics.
|
||||||
|
- Existing code already loads shards.
|
||||||
|
|
||||||
|
Cons:
|
||||||
|
|
||||||
|
- CPU inference is much slower than llama.cpp/GGUF.
|
||||||
|
- Current distributed path bypasses `generate()` and disables cache.
|
||||||
|
- Quantized GGUF ecosystem and LM Studio users are outside the runtime.
|
||||||
|
|
||||||
|
### B. Use llama.cpp only as a full local model backend
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
|
||||||
|
- Quick performance win for nodes with enough RAM/VRAM.
|
||||||
|
- Minimal coordination with distributed protocol.
|
||||||
|
|
||||||
|
Cons:
|
||||||
|
|
||||||
|
- Does not unlock 397B/753B-class models for ordinary nodes.
|
||||||
|
- Does not solve marketplace layer routing.
|
||||||
|
|
||||||
|
### C. Distributed GGUF with per-shard local KV (chosen)
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
|
||||||
|
- Aligns with torrent artifact distribution.
|
||||||
|
- Avoids root streaming weights to workers.
|
||||||
|
- Uses llama.cpp/GGUF performance where supported.
|
||||||
|
- Compatible with public node rewards by layer/work contribution.
|
||||||
|
- Scales KV memory by layer range.
|
||||||
|
|
||||||
|
Cons:
|
||||||
|
|
||||||
|
- Requires new runtime APIs around layer-boundary hidden states and per-session KV.
|
||||||
|
- Requires model-specific cache metadata for DSA/MLA/hybrid attention.
|
||||||
|
- Harder to debug than single-process `generate()`.
|
||||||
|
|
||||||
|
### D. Centralized KV cache servers
|
||||||
|
|
||||||
|
Pros:
|
||||||
|
|
||||||
|
- Easier apparent session failover.
|
||||||
|
- Central accounting of active cache.
|
||||||
|
|
||||||
|
Cons:
|
||||||
|
|
||||||
|
- Puts remote storage in the per-token hot path.
|
||||||
|
- Adds bandwidth and latency at the worst possible point.
|
||||||
|
- Creates consistency and privacy problems.
|
||||||
|
|
||||||
|
Rejected for hot decode. Accepted only for cold prefix snapshots and failover checkpoints.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- ADR-0001 should eventually be amended: PyTorch remains valid, but llama.cpp/GGUF becomes a first-class backend.
|
||||||
|
- The activation protocol must split prefill and decode explicitly.
|
||||||
|
- Session IDs must be stable across the full request. The current fresh UUID-per-hop-call behavior must change.
|
||||||
|
- Backends must report cache budget and cache compatibility.
|
||||||
|
- Tracker route selection must include disk, memory pressure, cache warmth, and network latency.
|
||||||
|
- Billing can be based on layer work, prefill tokens, decode tokens, and observed route participation.
|
||||||
|
- Client UX should stream token deltas when feasible and must include route-session progress telemetry even when token deltas are not streamed.
|
||||||
|
|
||||||
|
## Required Runtime Capabilities
|
||||||
|
|
||||||
|
PyTorch path:
|
||||||
|
|
||||||
|
- manual layer calls with `past_key_values` / model-specific cache object
|
||||||
|
- per-shard session cache store
|
||||||
|
- prefill chunk append
|
||||||
|
- decode step append
|
||||||
|
- stable session lifecycle endpoints
|
||||||
|
|
||||||
|
llama.cpp/GGUF path:
|
||||||
|
|
||||||
|
- full local GGUF serving
|
||||||
|
- layer/tensor map extraction from GGUF
|
||||||
|
- optional partial layer loading or mmap-backed selected execution
|
||||||
|
- inbound hidden-state execution from arbitrary start layer
|
||||||
|
- outbound hidden-state return at stop layer
|
||||||
|
- per-session KV ownership for loaded layers
|
||||||
|
- cache budget/compatibility introspection
|
||||||
|
- GLM-5.2 DSA support when upstream/runtime supports it
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
1. Add full-model `LlamaCppBackend` using `llama-server` or `libllama`.
|
||||||
|
2. Implement distributed KV in the PyTorch path to prove semantics.
|
||||||
|
3. Add session lifecycle and prefill/decode wire protocol.
|
||||||
|
4. Add model artifact manifest and torrent seeding metadata.
|
||||||
|
5. Prototype localhost two-process llama.cpp layer boundary execution.
|
||||||
|
6. Generalize to network route.
|
||||||
|
7. Bring in GLM-5.2/Ornith once backend support and cache accounting are verified.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- A two-node localhost route can prefill once and decode N tokens without recomputing the full prompt.
|
||||||
|
- Seam payload during decode is `O(hidden_size)`, not `O(sequence_length * hidden_size)`.
|
||||||
|
- Per-node KV memory grows with owned layer count and context length.
|
||||||
|
- Route loss during alpha fails cleanly with explicit reason.
|
||||||
|
- Full local GGUF backend outperforms PyTorch CPU on a supported model.
|
||||||
|
- Artifact manifest can identify exactly which files/chunks a node must seed for its advertised layer range.
|
||||||
83
.scratch/distributed-gguf-runtime/PRD.md
Normal file
83
.scratch/distributed-gguf-runtime/PRD.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# PRD: Distributed GGUF Runtime
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Build a distributed inference runtime that can serve large, quality-first open models by combining torrent-style model artifact distribution with sticky multi-node Inference Routes and per-shard local Hot KV State.
|
||||||
|
|
||||||
|
The first runtime proof uses the existing PyTorch route because it exposes model internals and cache semantics more directly. GGUF/llama.cpp becomes the performance path after the route-session contract is proven.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Eliminate full-prompt recompute in distributed decode.
|
||||||
|
- Keep decode activation seams proportional to `hidden_size`, not `context_length * hidden_size`.
|
||||||
|
- Keep Hot KV State local to the node serving the relevant Shard.
|
||||||
|
- Stream token deltas when feasible and always expose Generation Telemetry.
|
||||||
|
- Add a local full-model GGUF backend for immediate CPU performance wins.
|
||||||
|
- Define Model Artifact manifests so nodes can verify, seed, and advertise artifacts without depending on Hugging Face at request time.
|
||||||
|
- Prototype an upstreamable llama.cpp/libllama layer-boundary API.
|
||||||
|
- Use DeepSeek-V4-Flash as the first serious large-model target after smaller protocol smoke tests.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No centralized hot KV cache in the per-token decode path.
|
||||||
|
- No automatic route repair in alpha.
|
||||||
|
- No permanent llama.cpp fork as the intended architecture.
|
||||||
|
- No GLM-5.2 or Ornith first; they remain follow-up support audits.
|
||||||
|
- No transport rewrite to QUIC/WebRTC before route/session semantics are proven.
|
||||||
|
|
||||||
|
## Resolved Decisions
|
||||||
|
|
||||||
|
- Public-network Shards are contiguous transformer layer ranges.
|
||||||
|
- Tensor/ring parallelism belongs inside one trusted node, one colocated pod, or a future composite node abstraction.
|
||||||
|
- Hot KV State is local to route nodes; Prefix Snapshots are optional cold recovery/reuse artifacts.
|
||||||
|
- PyTorch distributed KV/session semantics are proven before llama.cpp distributed execution.
|
||||||
|
- Streaming responses are preferred; Generation Telemetry is mandatory.
|
||||||
|
- llama.cpp/GGUF work targets upstreamable `libllama`/ggml hooks.
|
||||||
|
- Alpha fails Route Sessions on route-node loss.
|
||||||
|
- v1 activation transfer stays on binary HTTP.
|
||||||
|
|
||||||
|
## Target User Experience
|
||||||
|
|
||||||
|
A client sends an OpenAI-compatible request. The Gateway or Tracker Node accepts the request, creates a Route Session, and streams token deltas when supported. The client receives live Generation Telemetry for route phase, prefill progress, generated token count, rolling tokens/sec, route health, and failure reason.
|
||||||
|
|
||||||
|
If a route node drops in alpha, the request fails clearly. A retry starts a new Route Session from scratch.
|
||||||
|
|
||||||
|
## Runtime Shape
|
||||||
|
|
||||||
|
```text
|
||||||
|
client request
|
||||||
|
-> Gateway / Tracker Node creates Route Session
|
||||||
|
-> Tracker selects sticky Inference Route
|
||||||
|
-> prefill:
|
||||||
|
prompt chunks move through Shards
|
||||||
|
each node appends local Hot KV State
|
||||||
|
-> decode:
|
||||||
|
one-step activation moves through Shards
|
||||||
|
each node reads/appends local Hot KV State
|
||||||
|
tail returns token/logits
|
||||||
|
-> client receives streamed token deltas where possible
|
||||||
|
-> Generation Telemetry continues until complete or failed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| Milestone | Outcome | Issues |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 — Session protocol proof | Stub route has stable Route Sessions, prefill/decode split, telemetry, and streaming contract | 01, 02, 03 |
|
||||||
|
| M2 — PyTorch reference route | Distributed PyTorch decode uses local per-shard cache and stops full-prompt recompute | 04 |
|
||||||
|
| M3 — Local GGUF performance path | Single-node GGUF backend serves through the node API and reports backend metadata | 05 |
|
||||||
|
| M4 — Artifact plane | Model Artifact manifest supports verification, layer mapping, and node advertisement | 06 |
|
||||||
|
| M5 — llama.cpp collaboration proof | Localhost layer-boundary prototype identifies upstreamable llama.cpp/libllama API | 07 |
|
||||||
|
| M6 — Networked GGUF route | Multi-node GGUF route uses the resolved protocol and fails cleanly on node loss | 08 |
|
||||||
|
| M7 — First large model | DeepSeek-V4-Flash support path is audited and converted into follow-up runtime tasks | 09 |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- A two-node route can prefill once and decode without resending full prompt activations.
|
||||||
|
- Decode seam payload is one token/hidden-state step after prefill.
|
||||||
|
- Route Session telemetry is visible before first token and during decode.
|
||||||
|
- Streaming token deltas work where the backend supports them.
|
||||||
|
- Route-node loss produces a structured alpha failure and does not attempt unsafe repair.
|
||||||
|
- A local GGUF model can serve via the node API.
|
||||||
|
- A Model Artifact manifest can prove which Shards a node can serve.
|
||||||
|
- DeepSeek-V4-Flash has a written support recommendation: PyTorch, vLLM/SGLang, llama.cpp/GGUF, or blocked.
|
||||||
63
.scratch/distributed-gguf-runtime/README.md
Normal file
63
.scratch/distributed-gguf-runtime/README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Distributed GGUF runtime — planning index
|
||||||
|
|
||||||
|
Status: draft scratch package.
|
||||||
|
|
||||||
|
Goal: make the node network capable of serving large, high-quality open models by distributing GGUF/model artifacts over a torrent-style swarm while executing inference over a sticky multi-node route with per-shard local KV cache.
|
||||||
|
|
||||||
|
This scratch supersedes the old assumption in [ADR-0001](../../docs/adr/0001-pytorch-over-llama-cpp.md) that llama.cpp is only a single-node leaf backend. That assumption was correct for the original llama.cpp RPC shape, but the target is now different: torrent-distributed GGUF artifacts plus an explicit route/KV protocol owned by this platform, ideally developed in collaboration with upstream llama.cpp.
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [architecture.md](./architecture.md) | Proposed runtime architecture, data flow, session state, and failure model |
|
||||||
|
| [technical-challenges.md](./technical-challenges.md) | Detailed challenge/solution register with acceptance tests |
|
||||||
|
| [decision-framework.md](./decision-framework.md) | Grilling framework for open decisions and recommended answers |
|
||||||
|
| [research-prior-art.md](./research-prior-art.md) | Prior-art notes for Petals, exo, Distributed Llama, prima.cpp, llama.cpp, DeepSeek-V4-Flash, GLM-5.2, and Ornith |
|
||||||
|
| [ADR-0020-distributed-gguf-runtime.md](./ADR-0020-distributed-gguf-runtime.md) | Draft decision record for the GGUF/llama.cpp distributed runtime |
|
||||||
|
| [PRD.md](./PRD.md) | Product/runtime requirements and acceptance criteria |
|
||||||
|
| [milestones.md](./milestones.md) | Dependency-ordered implementation milestones |
|
||||||
|
| [issues/](./issues/) | Implementation-ready tracer-bullet issue briefs |
|
||||||
|
|
||||||
|
## Decision Summary
|
||||||
|
|
||||||
|
Adopt a hybrid runtime:
|
||||||
|
|
||||||
|
- **Weights and artifacts**: distributed by torrent / content-addressed storage / optional CDN.
|
||||||
|
- **Hot KV cache**: local to the node that owns the corresponding layer range.
|
||||||
|
- **Prefix snapshots**: optionally persisted to cache servers for reuse, retry, and failover.
|
||||||
|
- **Active route**: sticky for one request/session.
|
||||||
|
- **Context cap**: 128K hard product limit for large models unless explicitly revised.
|
||||||
|
- **Backends**: keep PyTorch for fast model-architecture coverage and validation; add llama.cpp/GGUF as the performance path for supported models.
|
||||||
|
- **Client feedback**: stream token deltas when feasible; always expose Generation Telemetry.
|
||||||
|
- **First serious target model**: DeepSeek-V4-Flash after a smaller GGUF protocol smoke test.
|
||||||
|
|
||||||
|
## What We Learned
|
||||||
|
|
||||||
|
- Our current full-model PyTorch path uses Transformers `generate()` and gets local KV cache.
|
||||||
|
- Our current distributed PyTorch path disables cache and recomputes the full growing sequence per token.
|
||||||
|
- The seam today carries hidden activations, not KV cache; at 128K this becomes impossible for serious models if repeated every decode token.
|
||||||
|
- The missing capability is not "send KV across the network"; it is **stable per-session local KV cache per shard**.
|
||||||
|
- GGUF distribution is solved enough at the artifact layer, but GGUF/llama.cpp needs explicit layer-boundary execution APIs for our route model.
|
||||||
|
|
||||||
|
## Recommended Order
|
||||||
|
|
||||||
|
See [milestones.md](./milestones.md) for the full dependency map.
|
||||||
|
|
||||||
|
1. [01 — Route Session lifecycle](./issues/01-route-session-lifecycle.md)
|
||||||
|
2. [02 — Prefill/decode binary HTTP protocol](./issues/02-prefill-decode-binary-http.md)
|
||||||
|
3. [03 — Generation Telemetry and streaming response contract](./issues/03-generation-telemetry-and-streaming.md)
|
||||||
|
4. [04 — PyTorch distributed KV reference route](./issues/04-pytorch-distributed-kv-reference.md)
|
||||||
|
5. [05 — Local llama.cpp/GGUF backend](./issues/05-local-llamacpp-gguf-backend.md)
|
||||||
|
6. [06 — Model Artifact manifest and Shard advertisement](./issues/06-model-artifact-manifest.md)
|
||||||
|
7. [07 — llama.cpp layer-boundary prototype](./issues/07-llamacpp-layer-boundary-prototype.md)
|
||||||
|
8. [08 — Networked distributed GGUF route](./issues/08-networked-distributed-gguf-route.md)
|
||||||
|
9. [09 — DeepSeek-V4-Flash support audit](./issues/09-deepseek-v4-flash-support-audit.md)
|
||||||
|
10. [10 — GLM-5.2 and Ornith follow-up support audit](./issues/10-glm52-ornith-followup-audit.md)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Does upstream llama.cpp already expose enough internal API for arbitrary layer-range execution and hidden-state boundary I/O, or do we need an extension?
|
||||||
|
- Can GGUF split metadata be made layer/tensor semantic enough for torrent placement and partial loading?
|
||||||
|
- What is the minimum protocol needed for compressed KV formats such as GLM-5.2 DSA/MLA without exposing model-specific internals to the tracker?
|
||||||
|
- How much reliability do we need in alpha: fail request on route loss, or support route repair with KV snapshots?
|
||||||
274
.scratch/distributed-gguf-runtime/architecture.md
Normal file
274
.scratch/distributed-gguf-runtime/architecture.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# Distributed GGUF Runtime Architecture
|
||||||
|
|
||||||
|
## Product Stance
|
||||||
|
|
||||||
|
The platform optimizes for access to high-quality models, not lowest latency. Latency is acceptable if the user can run models that are otherwise unavailable to them. The hard context limit for the first serious distributed runtime should be **128K tokens**. Longer context usually means the product is compensating for missing task decomposition, retrieval, or workspace summarization.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
The current node has two materially different inference paths:
|
||||||
|
|
||||||
|
- **Full local PyTorch model**: calls Hugging Face `model.generate()`, so Transformers owns autoregressive decode and local KV cache.
|
||||||
|
- **Distributed PyTorch route**: bypasses `model.generate()`, calls individual layers with `use_cache=False`, and recomputes the full growing sequence for every generated token.
|
||||||
|
|
||||||
|
Current distributed data flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
client request
|
||||||
|
-> head node formats prompt
|
||||||
|
-> for each output token:
|
||||||
|
head tokenizes full current text
|
||||||
|
head runs early layers over all tokens
|
||||||
|
head sends full activation [batch, sequence, hidden] to next node
|
||||||
|
middle nodes run their layers over all tokens
|
||||||
|
tail returns one decoded token string
|
||||||
|
head appends token to text
|
||||||
|
```
|
||||||
|
|
||||||
|
This is correct for small demos but not viable for large models. For GLM-5.2, a single 128K seam activation is roughly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
128K tokens * hidden_size 6144 * 2 bytes ~= 1.5 GiB per hop
|
||||||
|
```
|
||||||
|
|
||||||
|
Sending that every output token is the bottleneck.
|
||||||
|
|
||||||
|
## Target State
|
||||||
|
|
||||||
|
Target distributed data flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
client request
|
||||||
|
-> tracker selects route and pins session
|
||||||
|
-> head node creates session_id
|
||||||
|
-> prefill:
|
||||||
|
prompt is chunked
|
||||||
|
each shard computes its layer range
|
||||||
|
each shard appends local KV/state for its own layers
|
||||||
|
activations cross only layer seams
|
||||||
|
-> decode loop:
|
||||||
|
head sends one new token / one-step hidden state
|
||||||
|
each shard reads local KV/state for session_id
|
||||||
|
each shard appends one step to local KV/state
|
||||||
|
only one-step activation crosses seams
|
||||||
|
tail returns logits/token
|
||||||
|
```
|
||||||
|
|
||||||
|
The KV cache remains local to the node that computed it. It is not sent to the next node and not read from a remote cache server during every decode step.
|
||||||
|
|
||||||
|
## Client Feedback
|
||||||
|
|
||||||
|
Streaming responses are desirable when the backend and client transport support them. The product should stream token deltas when possible, and it must always provide realtime Generation Telemetry while the route is working.
|
||||||
|
|
||||||
|
The fallback behavior is a non-streaming final answer plus live telemetry. That fallback is acceptable for early route proofs or models/backends that cannot expose clean token deltas yet, but the preferred client experience is streamed output plus telemetry.
|
||||||
|
|
||||||
|
Minimum client-visible telemetry:
|
||||||
|
|
||||||
|
- route/session accepted
|
||||||
|
- selected model and quantization
|
||||||
|
- prefill phase started/completed
|
||||||
|
- decode phase started
|
||||||
|
- generated token count
|
||||||
|
- rolling tokens per second
|
||||||
|
- route health or retry/failure reason
|
||||||
|
- estimated billing units when available
|
||||||
|
|
||||||
|
Implementation options:
|
||||||
|
|
||||||
|
- Server-Sent Events or WebSocket for realtime progress
|
||||||
|
- polling endpoint for simple clients
|
||||||
|
- OpenAI-compatible streaming for clients that require token deltas
|
||||||
|
|
||||||
|
This means "no token streaming" is acceptable only as a fallback. "Silent wait for minutes" is not acceptable.
|
||||||
|
|
||||||
|
## Artifact Plane
|
||||||
|
|
||||||
|
Artifact distribution is separate from execution.
|
||||||
|
|
||||||
|
```text
|
||||||
|
model publisher
|
||||||
|
-> produces model manifest
|
||||||
|
-> creates GGUF / safetensors / tokenizer artifacts
|
||||||
|
-> content-addresses every file/chunk
|
||||||
|
-> publishes torrent/magnet + HTTP fallback metadata
|
||||||
|
|
||||||
|
node
|
||||||
|
-> chooses model/layer range
|
||||||
|
-> downloads needed files/chunks
|
||||||
|
-> verifies hash
|
||||||
|
-> advertises availability to tracker
|
||||||
|
```
|
||||||
|
|
||||||
|
Required manifest fields:
|
||||||
|
|
||||||
|
- model id and version
|
||||||
|
- upstream source repo and revision
|
||||||
|
- license
|
||||||
|
- architecture name
|
||||||
|
- tokenizer files and hashes
|
||||||
|
- quantization
|
||||||
|
- tensor-to-layer map
|
||||||
|
- file/chunk hashes
|
||||||
|
- optional GGUF split files
|
||||||
|
- supported runtime backends
|
||||||
|
- context cap
|
||||||
|
- KV/cache format descriptor
|
||||||
|
|
||||||
|
## Execution Plane
|
||||||
|
|
||||||
|
The tracker selects routes using layer coverage and observed performance:
|
||||||
|
|
||||||
|
```text
|
||||||
|
route = [
|
||||||
|
head node: embeddings + layers 0..k
|
||||||
|
middle nodes: contiguous layer ranges
|
||||||
|
tail node: final layers + norm + lm_head
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Route selection inputs:
|
||||||
|
|
||||||
|
- model id/version/quantization
|
||||||
|
- layer coverage
|
||||||
|
- node hardware
|
||||||
|
- measured prefill throughput
|
||||||
|
- measured decode throughput
|
||||||
|
- queue depth
|
||||||
|
- latency to neighboring nodes
|
||||||
|
- cache warmth for the requested prefix/session
|
||||||
|
- reliability/reputation
|
||||||
|
|
||||||
|
The route is sticky for the request/session. A new route means either a fresh prefill or restoring compatible KV snapshots.
|
||||||
|
|
||||||
|
## KV Cache Ownership
|
||||||
|
|
||||||
|
KV/state ownership is by layer range:
|
||||||
|
|
||||||
|
```text
|
||||||
|
session_id = request scoped id
|
||||||
|
node A owns layers 0..15 KV for session_id
|
||||||
|
node B owns layers 16..31 KV for session_id
|
||||||
|
node C owns layers 32..77 KV for session_id
|
||||||
|
```
|
||||||
|
|
||||||
|
The tracker does not own hot KV. It may know which nodes hold active KV for session accounting and failure handling.
|
||||||
|
|
||||||
|
Cache servers may store:
|
||||||
|
|
||||||
|
- prompt-prefix snapshots
|
||||||
|
- session checkpoints for retry
|
||||||
|
- cold reusable context blocks
|
||||||
|
- audit samples
|
||||||
|
|
||||||
|
Cache servers must not be in the per-token hot loop unless colocated with the compute node.
|
||||||
|
|
||||||
|
## 128K KV Budget
|
||||||
|
|
||||||
|
GLM-5.2 compressed DSA/MLA-style estimate from config:
|
||||||
|
|
||||||
|
```text
|
||||||
|
layers = 78
|
||||||
|
kv_lora_rank = 512
|
||||||
|
qk_rope_head_dim = 64
|
||||||
|
dtype = bf16 = 2 bytes
|
||||||
|
context = 128K
|
||||||
|
|
||||||
|
per_token ~= 78 * (512 + 64) * 2 = 89,856 bytes ~= 87.75 KiB
|
||||||
|
128K total ~= 10.7 GiB
|
||||||
|
per layer ~= 137 MiB
|
||||||
|
```
|
||||||
|
|
||||||
|
This is feasible when sharded:
|
||||||
|
|
||||||
|
| Layer count | Approx active KV at 128K |
|
||||||
|
|---:|---:|
|
||||||
|
| 1 | 137 MiB |
|
||||||
|
| 10 | 1.37 GiB |
|
||||||
|
| 20 | 2.75 GiB |
|
||||||
|
| 78 | 10.7 GiB |
|
||||||
|
|
||||||
|
The exact runtime value depends on implementation and cache quantization, but the order of magnitude is acceptable.
|
||||||
|
|
||||||
|
## Protocol Sketch
|
||||||
|
|
||||||
|
### Prefill
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/sessions/{session_id}/prefill
|
||||||
|
Content-Type: application/octet-stream
|
||||||
|
X-Meshnet-Model: zai-org/GLM-5.2
|
||||||
|
X-Meshnet-Route-Id: ...
|
||||||
|
X-Meshnet-Token-Range: 0-2047
|
||||||
|
X-Meshnet-Shape: 1,2048,6144
|
||||||
|
X-Meshnet-Dtype: bfloat16
|
||||||
|
|
||||||
|
<activation bytes>
|
||||||
|
```
|
||||||
|
|
||||||
|
The receiver:
|
||||||
|
|
||||||
|
- validates route/session
|
||||||
|
- runs assigned layer range for that chunk
|
||||||
|
- appends local KV/state
|
||||||
|
- forwards resulting activation to next hop
|
||||||
|
|
||||||
|
### Decode
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/sessions/{session_id}/decode-step
|
||||||
|
Content-Type: application/octet-stream
|
||||||
|
X-Meshnet-Model: zai-org/GLM-5.2
|
||||||
|
X-Meshnet-Position: 131072
|
||||||
|
X-Meshnet-Shape: 1,1,6144
|
||||||
|
X-Meshnet-Dtype: bfloat16
|
||||||
|
|
||||||
|
<one-step activation bytes>
|
||||||
|
```
|
||||||
|
|
||||||
|
The receiver:
|
||||||
|
|
||||||
|
- loads local KV/state by `session_id`
|
||||||
|
- runs one decode step for assigned layers
|
||||||
|
- appends one token position to local KV/state
|
||||||
|
- forwards one-step activation
|
||||||
|
|
||||||
|
## GGUF / llama.cpp Integration
|
||||||
|
|
||||||
|
The target llama.cpp integration needs more than `llama-server`.
|
||||||
|
|
||||||
|
Required capabilities:
|
||||||
|
|
||||||
|
- load full GGUF locally for immediate single-node performance
|
||||||
|
- optionally load only selected tensors/layers
|
||||||
|
- execute a layer range against inbound hidden states
|
||||||
|
- expose outbound hidden states at a boundary
|
||||||
|
- own per-session KV/state for only the loaded layer range
|
||||||
|
- support prefill chunks and decode-step calls
|
||||||
|
- expose model-specific cache metadata for DSA/MLA without requiring the tracker to understand tensor internals
|
||||||
|
|
||||||
|
If llama.cpp cannot expose these as stable APIs today, the collaboration target is an upstream extension rather than a long-lived fork.
|
||||||
|
|
||||||
|
## Failure Model
|
||||||
|
|
||||||
|
Alpha behavior:
|
||||||
|
|
||||||
|
- Route node drops during prefill: fail request and retry from scratch.
|
||||||
|
- Route node drops during decode: fail request unless a recent KV snapshot exists.
|
||||||
|
- Tracker restart: active sessions may be lost; completed billing records persist.
|
||||||
|
- Node restart: local hot KV is lost.
|
||||||
|
|
||||||
|
Later behavior:
|
||||||
|
|
||||||
|
- periodic KV snapshots for long sessions
|
||||||
|
- prefix cache reuse across requests
|
||||||
|
- route repair when a semantically equivalent node has the same model/layer range and compatible cache snapshot
|
||||||
|
|
||||||
|
## Security And Trust
|
||||||
|
|
||||||
|
Activation/KV data can reveal user prompts. Public volunteer routes are not private. For sensitive workloads:
|
||||||
|
|
||||||
|
- use private swarms
|
||||||
|
- allow paid trusted nodes
|
||||||
|
- encrypt transport
|
||||||
|
- avoid storing hot KV on untrusted shared cache servers
|
||||||
|
- sample outputs for fraud/audit as already planned in alpha hardening
|
||||||
268
.scratch/distributed-gguf-runtime/decision-framework.md
Normal file
268
.scratch/distributed-gguf-runtime/decision-framework.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# Distributed GGUF Decision Framework
|
||||||
|
|
||||||
|
This framework is for grilling open decisions. It keeps decisions tied to project vocabulary and implementation gates instead of vague "distributed inference" language.
|
||||||
|
|
||||||
|
## Core Vocabulary
|
||||||
|
|
||||||
|
Use the existing domain terms this way:
|
||||||
|
|
||||||
|
- **Shard**: contiguous transformer layer range. This is the compute, routing, cache, and reward unit.
|
||||||
|
- **Shard Swarm**: storage/download group for artifacts needed by a shard.
|
||||||
|
- **Inference Route**: ordered node sequence that covers all layers for one request.
|
||||||
|
- **Route Session**: one active request bound to one inference route and stable session id.
|
||||||
|
- **Hot KV State**: live per-shard cache held by the route node during a route session.
|
||||||
|
- **Prefix Snapshot**: persisted route-session state used for reuse or failover, not the hot decode path.
|
||||||
|
- **Artifact Manifest**: canonical mapping from model artifacts to semantic model parts and runtime support.
|
||||||
|
- **Generation Telemetry**: realtime progress for a route session, including phase and tokens/sec, independent of whether token deltas are streamed.
|
||||||
|
|
||||||
|
## The Five Planes
|
||||||
|
|
||||||
|
### 1. Control Plane
|
||||||
|
|
||||||
|
Owner: Tracker.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- node registry
|
||||||
|
- coverage map
|
||||||
|
- route selection
|
||||||
|
- rebalance directives
|
||||||
|
- route-session creation
|
||||||
|
- health and telemetry
|
||||||
|
- client-visible Generation Telemetry
|
||||||
|
- billing/audit records
|
||||||
|
|
||||||
|
Must not do:
|
||||||
|
|
||||||
|
- serve hot KV during every token
|
||||||
|
- become the only place model artifacts can be fetched
|
||||||
|
|
||||||
|
### 2. Artifact Plane
|
||||||
|
|
||||||
|
Owner: Shard Swarms, local node storage, optional CDN/bootstrap mirrors.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- GGUF/safetensors/tokenizer download
|
||||||
|
- content-addressed verification
|
||||||
|
- local artifact inventory
|
||||||
|
- artifact-to-layer mapping
|
||||||
|
- cache eviction
|
||||||
|
|
||||||
|
Must not do:
|
||||||
|
|
||||||
|
- define execution order by file split alone
|
||||||
|
- imply that a downloaded file chunk equals a Shard
|
||||||
|
|
||||||
|
### 3. Execution Plane
|
||||||
|
|
||||||
|
Owner: active Inference Route.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- chunked prefill
|
||||||
|
- one-step decode
|
||||||
|
- hidden-state transfer across activation seams
|
||||||
|
- start-layer handling for overlapping shards
|
||||||
|
- backpressure
|
||||||
|
|
||||||
|
Must not do:
|
||||||
|
|
||||||
|
- resend full context activations during decode
|
||||||
|
- require cross-node tensor parallel all-reduce for public v1
|
||||||
|
|
||||||
|
### 4. Session State Plane
|
||||||
|
|
||||||
|
Owner: route nodes for hot KV; cache servers only for snapshots.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- per-shard local KV ownership
|
||||||
|
- cache allocation and eviction
|
||||||
|
- cache ABI compatibility
|
||||||
|
- session close/release
|
||||||
|
- optional prefix snapshots
|
||||||
|
|
||||||
|
Must not do:
|
||||||
|
|
||||||
|
- centralize hot KV in a remote service
|
||||||
|
- let a replacement node continue from incompatible state
|
||||||
|
|
||||||
|
### 5. Economics And Trust Plane
|
||||||
|
|
||||||
|
Owner: tracker plus settlement/validation components.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- distinguish storage/seeding work from inference work
|
||||||
|
- account for prefill and decode separately
|
||||||
|
- record route participation
|
||||||
|
- sample validation events
|
||||||
|
- slash proven fraud
|
||||||
|
|
||||||
|
Must not do:
|
||||||
|
|
||||||
|
- pay a node for merely holding files as if it generated tokens
|
||||||
|
- hide public-swarm privacy limits from clients
|
||||||
|
|
||||||
|
## Hard Invariants
|
||||||
|
|
||||||
|
These are the framework rules unless we deliberately write a new ADR:
|
||||||
|
|
||||||
|
1. Public-network Shards are contiguous layer ranges.
|
||||||
|
2. Hot KV State is local to the node serving that Shard in that Route Session.
|
||||||
|
3. Artifact distribution and route execution are separate systems.
|
||||||
|
4. Decode seam payload must be `O(hidden_size)`.
|
||||||
|
5. Prefill may be `O(sequence_length * hidden_size)`, but only in bounded chunks.
|
||||||
|
6. The tracker chooses routes; nodes do not negotiate route topology peer-to-peer.
|
||||||
|
7. Model/backend-specific cache internals stay behind backend capability reports.
|
||||||
|
8. PyTorch remains the correctness/reference backend while llama.cpp/GGUF becomes the performance backend.
|
||||||
|
9. Streaming responses are preferred when feasible; Generation Telemetry is always required.
|
||||||
|
|
||||||
|
## Resolved Gates
|
||||||
|
|
||||||
|
### Gate 1: Public Shard Semantics
|
||||||
|
|
||||||
|
Decision: public-network Shards are contiguous transformer layer ranges. Tensor-parallel or ring-style execution is allowed only inside one trusted node, one colocated pod, or a future composite node abstraction.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Layer ranges match the existing `Shard`, `Coverage Map`, `Inference Route`, billing, and fraud vocabulary.
|
||||||
|
- Public volunteer nodes should not require cross-node all-reduce or tight per-layer synchronization in v1.
|
||||||
|
- Existing projects such as prima.cpp and Distributed Llama can still inform local-cluster/backend execution without becoming the public routing primitive.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- Artifact Manifests must map files/tensors to semantic layer ranges.
|
||||||
|
- Route selection remains ordered layer coverage.
|
||||||
|
- Rewards can be attributed to layer-range work.
|
||||||
|
- Hot KV State is naturally owned by the node serving that layer range for the Route Session.
|
||||||
|
|
||||||
|
### Gate 2: Hot KV Strategy
|
||||||
|
|
||||||
|
Decision: v1 rejects centralized hot KV. Hot KV State is local to the node serving the relevant Shard in the active Route Session. Cache servers may store Prefix Snapshots for reuse, retry, or failover, but they are not in the per-token decode path.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Decode is the tight loop; adding remote cache I/O there makes latency and bandwidth worse at the worst point.
|
||||||
|
- Local KV naturally follows layer-range Shard ownership.
|
||||||
|
- Centralized hot KV increases privacy exposure and creates consistency problems.
|
||||||
|
- Prefix Snapshots preserve the useful part of central storage without making it mandatory for every generated token.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- Route Session must be sticky.
|
||||||
|
- Failover is limited in alpha unless a compatible Prefix Snapshot exists.
|
||||||
|
- Cache servers are optimization infrastructure, not required runtime infrastructure.
|
||||||
|
- Route repair requires compatible model revision, layer range, backend cache ABI, and snapshot position.
|
||||||
|
|
||||||
|
### Gate 3: First Runtime Proof
|
||||||
|
|
||||||
|
Decision: prove distributed Route Session and Hot KV State semantics in the existing PyTorch route before modifying llama.cpp/GGUF.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- PyTorch exposes model internals and cache objects more directly, so it is the fastest way to validate the distributed protocol.
|
||||||
|
- The current distributed PyTorch route already has the right high-level shape but disables cache and recomputes full prompts.
|
||||||
|
- Fixing that path gives us a reference implementation for correctness tests, telemetry, session lifecycle, and wire protocol behavior.
|
||||||
|
- llama.cpp/GGUF should receive a clear target ABI rather than becoming both the protocol experiment and the performance backend at once.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- Issue 02 precedes issue 05.
|
||||||
|
- llama.cpp collaboration has a concrete target ABI.
|
||||||
|
- The PyTorch route remains the architecture-coverage/reference backend even after GGUF becomes the preferred performance path.
|
||||||
|
- The first success metric is eliminating full-prompt recompute in distributed decode.
|
||||||
|
|
||||||
|
### Gate 3A: Client Feedback During Latency
|
||||||
|
|
||||||
|
Decision: streaming responses are preferred when feasible, and realtime Generation Telemetry is required regardless of streaming support.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- The product optimizes for access to large capable models, so some latency is acceptable.
|
||||||
|
- Users still need confidence that the route is alive and roughly how fast it is generating.
|
||||||
|
- Streaming token deltas give the best user experience when the backend exposes them cleanly.
|
||||||
|
- Tokens/sec remains useful during prefill, queueing, and any backend that cannot stream token deltas.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- The gateway should stream token deltas through an OpenAI-compatible response when possible.
|
||||||
|
- The gateway must expose progress through SSE, WebSocket, or polling.
|
||||||
|
- The final answer can be delivered after completion only as a fallback.
|
||||||
|
- Telemetry must include route phase, generated token count, and rolling tokens/sec.
|
||||||
|
- Non-streaming clients still need realtime telemetry.
|
||||||
|
|
||||||
|
### Gate 4: llama.cpp Collaboration Shape
|
||||||
|
|
||||||
|
Decision: target upstreamable `libllama`/ggml hooks instead of planning around a permanent fork.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- llama.cpp changes quickly across model support, quantization, kernels, and hardware backends.
|
||||||
|
- A permanent fork would become expensive to maintain and would lag upstream improvements.
|
||||||
|
- A short-lived prototype branch is acceptable if it proves the API and makes upstream collaboration concrete.
|
||||||
|
- Keeping tracker/routing logic outside llama.cpp makes the upstream ask smaller and cleaner.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- Need a minimal reproducible localhost demo before asking upstream to carry the design.
|
||||||
|
- Need to separate "what llama.cpp should expose" from "what our tracker does".
|
||||||
|
- Desired upstream surface is layer-range execution, hidden-state boundary I/O, partial loading/introspection, and per-session KV ownership.
|
||||||
|
- If upstream rejects the shape, we revisit whether to carry a narrow adapter fork or keep GGUF distributed execution as experimental.
|
||||||
|
|
||||||
|
### Gate 5: First Model Target
|
||||||
|
|
||||||
|
Decision: use a two-tier model target. Use a small, boring, llama.cpp-supported GGUF model for the first protocol smoke test. Use `deepseek-ai/DeepSeek-V4-Flash` as the first serious large-model target. Keep GLM-5.2 and Ornith as later support audits.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- The first protocol proof should isolate route/session/KV bugs from model-architecture bugs.
|
||||||
|
- DeepSeek-V4-Flash is a strong first serious target because it is much smaller than 1.6T-class models while still being large enough to validate the product thesis.
|
||||||
|
- DeepSeek-V4-Flash still has architecture-specific risks, so it should not be the first smoke test.
|
||||||
|
- GLM-5.2 and Ornith remain valuable targets, but they add DSA/MLA/hybrid attention uncertainty.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- 128K cache accounting can be modeled now.
|
||||||
|
- The first "real" target-model audit is DeepSeek-V4-Flash support in PyTorch, vLLM/SGLang, and any available GGUF/llama.cpp quantization path.
|
||||||
|
- Production support waits for backend capability reports and exact cache ABI support.
|
||||||
|
|
||||||
|
### Gate 6: Failure Semantics
|
||||||
|
|
||||||
|
Decision: alpha fails Route Sessions on route-node loss instead of attempting automatic route repair.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Route repair requires compatible Prefix Snapshots, cache ABI checks, replacement-node selection, billing correction, and client stream/error recovery.
|
||||||
|
- Local Hot KV State means a replacement node cannot continue unless it has compatible state at the same position.
|
||||||
|
- Fail-fast keeps the first implementation correct while the session/KV protocol is still being proven.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- Better observability and explicit errors are required.
|
||||||
|
- Snapshotting becomes a later feature, not a blocker for first inference.
|
||||||
|
- Generation Telemetry must report the last known phase and failure reason.
|
||||||
|
- Client or gateway retry starts a new Route Session from scratch.
|
||||||
|
|
||||||
|
### Gate 7: Transport
|
||||||
|
|
||||||
|
Decision: keep binary HTTP for v1 activation transfer instead of jumping immediately to QUIC, WebRTC, or a custom transport.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- ADR-0008 already defines binary activation bodies with HTTP headers.
|
||||||
|
- HTTP keeps the first implementation debuggable with the existing server stack and tooling.
|
||||||
|
- The core risk is route/session/KV correctness, not transport optimization.
|
||||||
|
- QUIC/WebRTC can be introduced later behind the same activation protocol once semantics are proven.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- Focus benchmark work on payload shape, chunking, and cache behavior first.
|
||||||
|
- QUIC/WebRTC can be introduced as an optimization behind the same activation protocol.
|
||||||
|
- v1 implementation can reuse the current HTTP routing, relay, and observability infrastructure.
|
||||||
|
- Transport abstraction should be kept narrow enough that HTTP can be replaced later without changing backend cache semantics.
|
||||||
|
|
||||||
|
## Grilling Progress
|
||||||
|
|
||||||
|
Gates 1, 2, 3, 3A, 4, 5, 6, and 7 are resolved. The remaining work is to convert the resolved framework into implementation-ready issue briefs and prototype milestones.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 01 — Route Session lifecycle
|
||||||
|
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Add the narrowest end-to-end Route Session lifecycle that can be used by distributed inference routes: create a session, bind it to a selected Inference Route, expose status, and close it cleanly. This slice does not need real model cache yet; it proves stable session identity across the control plane and activation plane.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] A request can create a Route Session with a stable `session_id`, `route_id`, model preset, backend id, and route membership.
|
||||||
|
- [ ] Every downstream activation request carries the same session identity and fails clearly if the session or route id does not match.
|
||||||
|
- [ ] Session status reports phase, route nodes, model preset, backend id, created time, and last activity time.
|
||||||
|
- [ ] Closing a session releases all registered per-session state.
|
||||||
|
- [ ] Tests cover create, status, close, stale-session rejection, and wrong-route rejection.
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
None - can start immediately.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 02 — Prefill/decode binary HTTP protocol
|
||||||
|
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Split the activation protocol into explicit prefill and decode-step calls using the existing binary HTTP direction from ADR-0008. The completed slice should work against a stub backend so payload shape, route/session headers, relay preservation, and failure behavior are testable before real KV cache work begins.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Prefill accepts chunked binary activations with route/session metadata and forwards them through the selected route.
|
||||||
|
- [ ] Decode-step accepts a one-step binary activation and forwards a one-step activation through the selected route.
|
||||||
|
- [ ] Decode-step payload size is independent of prompt length in protocol tests.
|
||||||
|
- [ ] Relay forwarding preserves route/session headers, shape, dtype, position, and wire version.
|
||||||
|
- [ ] Legacy `/forward` either remains as a compatibility wrapper or fails with a clear wire-version error.
|
||||||
|
- [ ] Tests cover prefill chunking, decode-step shape validation, relay preservation, and malformed header rejection.
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- 01 — Route Session lifecycle.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 03 — Generation Telemetry and streaming response contract
|
||||||
|
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Expose realtime Generation Telemetry for active Route Sessions and stream token deltas when the serving path can produce them. This slice should make long distributed requests observable before real large-model work begins.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] A client can observe route-session phase changes: queued, loading, prefill, decode, finalizing, completed, failed.
|
||||||
|
- [ ] Telemetry includes prefill progress, generated token count, rolling tokens/sec, average tokens/sec, active route nodes, and failure reason.
|
||||||
|
- [ ] Telemetry is available before the first output token.
|
||||||
|
- [ ] A streaming response can include token deltas while telemetry remains available.
|
||||||
|
- [ ] A non-streaming fallback still exposes telemetry until final answer or failure.
|
||||||
|
- [ ] Route-node failure reports the last known phase and reason.
|
||||||
|
- [ ] Tests cover telemetry updates, streaming token deltas, non-streaming fallback, and structured failure closeout.
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- 01 — Route Session lifecycle.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 04 — PyTorch distributed KV reference route
|
||||||
|
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Fix the existing distributed PyTorch route so it uses the Route Session and prefill/decode protocol to keep Hot KV State local to each Shard node. The visible behavior is that prefill processes the prompt once, and decode no longer recomputes or resends the full growing prompt for every token.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Distributed PyTorch prefill stores per-session cache/state on each Shard node.
|
||||||
|
- [ ] Distributed PyTorch decode-step reads and appends local per-shard cache/state.
|
||||||
|
- [ ] Decode activation seam payload is one token/hidden-state step after prefill.
|
||||||
|
- [ ] The old full-growing-prompt decode loop is not used for models that support the reference cache path.
|
||||||
|
- [ ] Unsupported model/cache APIs fail with an explicit backend capability error.
|
||||||
|
- [ ] Session close or TTL cleanup releases per-shard cache.
|
||||||
|
- [ ] Regression tests prove decode does not call the full prompt encoder for every generated token.
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- 01 — Route Session lifecycle.
|
||||||
|
- 02 — Prefill/decode binary HTTP protocol.
|
||||||
|
- 03 — Generation Telemetry and streaming response contract.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 05 — Local llama.cpp/GGUF backend
|
||||||
|
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Add a local full-model GGUF backend so a node that can hold a GGUF model can serve it through the existing node API. This is the immediate CPU-performance path and the baseline for later distributed llama.cpp work.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] A node can start with backend `llama.cpp` or `gguf` for a local full-model GGUF artifact.
|
||||||
|
- [ ] The node can answer an OpenAI-compatible chat completion through the existing API.
|
||||||
|
- [ ] Startup and registration clearly report backend, quantization/artifact metadata, context cap, and local model path.
|
||||||
|
- [ ] The PyTorch backend remains unchanged and selectable.
|
||||||
|
- [ ] A smoke test or script validates backend wiring with a small GGUF model or a stubbed llama.cpp process.
|
||||||
|
- [ ] A benchmark command can compare local PyTorch CPU and local GGUF CPU for the same small supported model when both are available.
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
None - can start immediately.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 06 — Model Artifact manifest and Shard advertisement
|
||||||
|
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Introduce a Model Artifact manifest that separates storage distribution from route execution. A node should be able to verify local model files, determine which Shards it can serve, and advertise artifact/layer availability to the Tracker without contacting Hugging Face at request time.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Manifest records model preset, upstream revision, license, backend support, quantization, context cap, tokenizer artifacts, file hashes, piece hashes, and tensor/layer mapping where available.
|
||||||
|
- [ ] A node can verify local artifacts against the manifest and reject corrupt or incomplete artifacts.
|
||||||
|
- [ ] A node can derive advertised Shard ranges from the manifest and local files.
|
||||||
|
- [ ] Tracker registration can include artifact id, backend id, Shard range, and verification status.
|
||||||
|
- [ ] Tracker coverage can distinguish model-layer coverage from artifact availability.
|
||||||
|
- [ ] Tests cover valid manifest registration, corrupt artifact rejection, and missing layer/tensor metadata.
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- 01 — Route Session lifecycle.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 07 — llama.cpp layer-boundary prototype
|
||||||
|
|
||||||
|
Status: ready-for-human
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Build a local prototype that proves whether llama.cpp/libllama can support the platform's distributed execution contract: execute a selected layer range, accept inbound hidden states, emit outbound hidden states, and own per-session cache for only the loaded/served range.
|
||||||
|
|
||||||
|
This is the collaboration package for upstream llama.cpp. The target is an upstreamable API shape, not a permanent fork.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] A small llama.cpp-supported GGUF model can be split into a two-process localhost head/tail prototype.
|
||||||
|
- [ ] The head process runs embeddings and early layers, then emits hidden states at an Activation Seam.
|
||||||
|
- [ ] The tail process accepts hidden states, runs later layers plus output head, and produces logits/tokens comparable to single-process execution.
|
||||||
|
- [ ] Prefill is performed once and decode-step seam payload is one hidden-state step per generated token.
|
||||||
|
- [ ] Each process owns only its own per-session cache/state.
|
||||||
|
- [ ] The prototype records the minimum upstream API needed for layer-range execution, hidden-state I/O, partial loading/introspection, and per-session KV ownership.
|
||||||
|
- [ ] If upstream support is unavailable, the issue ends with a concrete recommendation: upstream proposal, narrow adapter fork, or keep GGUF distribution local-only for now.
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- 02 — Prefill/decode binary HTTP protocol.
|
||||||
|
- 05 — Local llama.cpp/GGUF backend.
|
||||||
|
- 06 — Model Artifact manifest and Shard advertisement.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 08 — Networked distributed GGUF route
|
||||||
|
|
||||||
|
Status: pending
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Run a GGUF-backed model over a real multi-node Inference Route using the resolved Route Session, binary HTTP prefill/decode protocol, local Hot KV State, Generation Telemetry, and alpha fail-fast behavior.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Two machines can form one GGUF-backed Inference Route over contiguous Shards.
|
||||||
|
- [ ] Prefill builds local per-shard cache/state and decode-step uses one-step seam payloads.
|
||||||
|
- [ ] The client receives streamed token deltas when supported by the GGUF path.
|
||||||
|
- [ ] The client receives Generation Telemetry for phase, generated tokens, tokens/sec, route health, and failure reason.
|
||||||
|
- [ ] Route-node loss fails the Route Session cleanly; no automatic repair is attempted in alpha.
|
||||||
|
- [ ] Tracker metrics show prefill tokens/sec, decode tokens/sec, seam latency, queue depth, and cache memory by node.
|
||||||
|
- [ ] Billing/audit records identify route membership and layer/token work for the completed or failed session.
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- 03 — Generation Telemetry and streaming response contract.
|
||||||
|
- 04 — PyTorch distributed KV reference route.
|
||||||
|
- 06 — Model Artifact manifest and Shard advertisement.
|
||||||
|
- 07 — llama.cpp layer-boundary prototype.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 09 — DeepSeek-V4-Flash support audit
|
||||||
|
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Audit `deepseek-ai/DeepSeek-V4-Flash` as the first serious large-model target after the small GGUF protocol smoke test. The output is a compatibility matrix and a recommended runtime path, not full production support.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Verify current PyTorch/Transformers load and generation semantics for DeepSeek-V4-Flash from primary model documentation.
|
||||||
|
- [ ] Verify vLLM and SGLang support status from primary runtime documentation or release notes.
|
||||||
|
- [ ] Verify whether a GGUF/llama.cpp quantization path exists or would need upstream work.
|
||||||
|
- [ ] Estimate artifact size, active parameter behavior, and 128K cache memory by Shard range.
|
||||||
|
- [ ] Identify required backend capability flags for the Tracker.
|
||||||
|
- [ ] Produce a compatibility matrix: PyTorch, vLLM, SGLang, llama.cpp/GGUF.
|
||||||
|
- [ ] End with one recommendation: first runtime path, blocked pending upstream, or defer.
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
None - can start immediately.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 10 — GLM-5.2 and Ornith follow-up support audit
|
||||||
|
|
||||||
|
Status: pending
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Audit GLM-5.2 and Ornith after the smaller protocol smoke path and DeepSeek-V4-Flash audit. The output is a follow-up compatibility matrix focused on architecture/runtime blockers: DSA/MLA, hybrid attention, cache accounting, and GGUF/llama.cpp support.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Verify GLM-5.2 PyTorch/Transformers serving requirements and cache semantics from primary model documentation.
|
||||||
|
- [ ] Verify llama.cpp/GGUF support status for `glm_moe_dsa` or equivalent architecture support.
|
||||||
|
- [ ] Verify Ornith/Qwen3.5-MoE and hybrid attention support status in the candidate runtimes.
|
||||||
|
- [ ] Estimate artifact size and 128K cache memory by Shard range for each model.
|
||||||
|
- [ ] Identify smallest quality-preserving quantization worth testing.
|
||||||
|
- [ ] Convert each runtime blocker into a follow-up issue or upstream collaboration note.
|
||||||
|
|
||||||
|
## Blocked by
|
||||||
|
|
||||||
|
- 09 — DeepSeek-V4-Flash support audit.
|
||||||
32
.scratch/distributed-gguf-runtime/milestones.md
Normal file
32
.scratch/distributed-gguf-runtime/milestones.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Distributed GGUF Runtime Milestones
|
||||||
|
|
||||||
|
## Proposed Breakdown
|
||||||
|
|
||||||
|
| Order | Issue | Title | Blocked by | User-visible proof |
|
||||||
|
|---:|---|---|---|---|
|
||||||
|
| 1 | [01](./issues/01-route-session-lifecycle.md) | Route Session lifecycle | None | Stable route/session status and cleanup |
|
||||||
|
| 2 | [02](./issues/02-prefill-decode-binary-http.md) | Prefill/decode binary HTTP protocol | 01 | Stub route proves prefill chunks and one-step decode payloads |
|
||||||
|
| 3 | [03](./issues/03-generation-telemetry-and-streaming.md) | Generation Telemetry and streaming response contract | 01 | Client sees route progress and streamed deltas when available |
|
||||||
|
| 4 | [04](./issues/04-pytorch-distributed-kv-reference.md) | PyTorch distributed KV reference route | 01, 02, 03 | Distributed PyTorch decode stops full-prompt recompute |
|
||||||
|
| 5 | [05](./issues/05-local-llamacpp-gguf-backend.md) | Local llama.cpp/GGUF backend | None | Local GGUF model serves through node API |
|
||||||
|
| 6 | [06](./issues/06-model-artifact-manifest.md) | Model Artifact manifest and Shard advertisement | 01 | Node verifies artifacts and advertises serveable Shards |
|
||||||
|
| 7 | [07](./issues/07-llamacpp-layer-boundary-prototype.md) | llama.cpp layer-boundary prototype | 02, 05, 06 | Local two-process GGUF route identifies upstream API |
|
||||||
|
| 8 | [08](./issues/08-networked-distributed-gguf-route.md) | Networked distributed GGUF route | 03, 04, 06, 07 | Two machines serve one GGUF route with telemetry |
|
||||||
|
| 9 | [09](./issues/09-deepseek-v4-flash-support-audit.md) | DeepSeek-V4-Flash support audit | None | Runtime recommendation for first serious large model |
|
||||||
|
| 10 | [10](./issues/10-glm52-ornith-followup-audit.md) | GLM-5.2 and Ornith follow-up support audit | 09 | Follow-up compatibility matrix and upstream blockers |
|
||||||
|
|
||||||
|
## First Three To Implement
|
||||||
|
|
||||||
|
1. **01 — Route Session lifecycle**: makes every later cache, telemetry, and route decision concrete.
|
||||||
|
2. **02 — Prefill/decode binary HTTP protocol**: proves the payload shape and route/session headers before model internals.
|
||||||
|
3. **03 — Generation Telemetry and streaming response contract**: gives every later long-running route a visible user experience and failure surface.
|
||||||
|
|
||||||
|
## Parallel Work
|
||||||
|
|
||||||
|
- **05 — Local llama.cpp/GGUF backend** can run in parallel with 01–03 because it is a full-model local backend.
|
||||||
|
- **09 — DeepSeek-V4-Flash support audit** can run in parallel because it is research/compatibility work.
|
||||||
|
|
||||||
|
## Human-Gated Work
|
||||||
|
|
||||||
|
- **07 — llama.cpp layer-boundary prototype** is the collaboration point with Georgi/upstream llama.cpp.
|
||||||
|
- **08 — Networked distributed GGUF route** should wait until the PyTorch reference route proves the cache/session contract.
|
||||||
231
.scratch/distributed-gguf-runtime/research-prior-art.md
Normal file
231
.scratch/distributed-gguf-runtime/research-prior-art.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Prior Art: Distributed Large-Model Inference
|
||||||
|
|
||||||
|
This note captures what existing projects appear to solve and what remains specific to this platform.
|
||||||
|
|
||||||
|
## Petals
|
||||||
|
|
||||||
|
Source: <https://github.com/bigscience-workshop/petals>
|
||||||
|
|
||||||
|
Petals is the closest conceptual match for public volunteer inference. Its README describes running large models "BitTorrent-style", where a user loads a model through a `transformers`-like API and connects to a distributed network that hosts model layers. It explicitly supports seeing hidden states and using PyTorch/Transformers flexibility. The public README also notes privacy limitations: data is processed by other people in the public swarm, and sensitive use should run in a private swarm.
|
||||||
|
|
||||||
|
What it solves:
|
||||||
|
|
||||||
|
- public swarm of layer-serving peers
|
||||||
|
- hidden-state exposure
|
||||||
|
- route-like execution over model blocks
|
||||||
|
- private swarm option
|
||||||
|
- PyTorch/Transformers integration
|
||||||
|
|
||||||
|
What it does not directly solve for us:
|
||||||
|
|
||||||
|
- GGUF/llama.cpp artifact path
|
||||||
|
- torrent artifact storage tied to node rewards
|
||||||
|
- our billing/fraud/reputation model
|
||||||
|
- our OpenAI-compatible tracker/node route model
|
||||||
|
- a production path for GLM-5.2/DSA GGUF
|
||||||
|
|
||||||
|
Design import:
|
||||||
|
|
||||||
|
- Keep a PyTorch route as a reference implementation and validation harness.
|
||||||
|
- Preserve hidden-state seam semantics.
|
||||||
|
- Treat privacy as an explicit swarm property.
|
||||||
|
|
||||||
|
## exo
|
||||||
|
|
||||||
|
Source: <https://github.com/exo-explore/exo>
|
||||||
|
|
||||||
|
exo connects local devices into an AI cluster. Its README emphasizes automatic device discovery, topology-aware model splitting, tensor parallelism, MLX support, RDMA over Thunderbolt, and multiple API compatibilities. It is strongest for colocated owned devices, especially Apple Silicon / MLX clusters.
|
||||||
|
|
||||||
|
What it solves:
|
||||||
|
|
||||||
|
- automatic local cluster discovery
|
||||||
|
- topology-aware splitting
|
||||||
|
- tensor parallelism
|
||||||
|
- OpenAI/Ollama/Claude API compatibility
|
||||||
|
- model placement previews
|
||||||
|
- cluster dashboard
|
||||||
|
|
||||||
|
What it does not directly solve for us:
|
||||||
|
|
||||||
|
- untrusted internet volunteer network
|
||||||
|
- reward, fraud, and reputation
|
||||||
|
- torrent artifact distribution
|
||||||
|
- Linux GPU maturity is stated as still under development in the README
|
||||||
|
- GGUF/llama.cpp route protocol
|
||||||
|
|
||||||
|
Design import:
|
||||||
|
|
||||||
|
- Add placement previews before committing a route.
|
||||||
|
- Model prefill/decode separately in benchmarks.
|
||||||
|
- Use topology-aware routing, not just layer coverage.
|
||||||
|
|
||||||
|
## Distributed Llama / dllama
|
||||||
|
|
||||||
|
Source: <https://github.com/b4rtaz/distributed-llama>
|
||||||
|
|
||||||
|
Distributed Llama connects home devices into a cluster for CPU/GPU inference. Its README describes tensor parallelism, Ethernet synchronization, Linux/macOS/Windows support, ARM and x86 AVX2 optimization, and a root/worker architecture. The root node loads the model and forwards weights/state to workers. Known limitations include only `2^n` nodes and a maximum node count equal to the model's number of KV heads.
|
||||||
|
|
||||||
|
What it solves:
|
||||||
|
|
||||||
|
- practical cross-platform home-device cluster
|
||||||
|
- tensor-parallel synchronization
|
||||||
|
- root/worker process model
|
||||||
|
- custom model format and conversion path
|
||||||
|
|
||||||
|
What it does not directly solve for us:
|
||||||
|
|
||||||
|
- arbitrary volunteer joins/leaves
|
||||||
|
- independent shard ownership from local/torrent disk
|
||||||
|
- layer-range routing with tracker-managed marketplace
|
||||||
|
- public network fraud/billing
|
||||||
|
- GGUF as the native published artifact
|
||||||
|
|
||||||
|
Design import:
|
||||||
|
|
||||||
|
- KV-head constraints matter for tensor-parallel designs.
|
||||||
|
- A root node that distributes weights is unacceptable for our torrent-first marketplace; nodes must independently acquire artifacts.
|
||||||
|
|
||||||
|
## prima.cpp
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
|
||||||
|
- <https://github.com/Lizonghang/prima.cpp>
|
||||||
|
- <https://arxiv.org/abs/2504.08791>
|
||||||
|
|
||||||
|
prima.cpp is a distributed llama.cpp implementation for low-resource home clusters. The README highlights mmap-based low memory pressure, piped-ring parallelism with prefetching, heterogeneity-aware workload distribution, automatic weak-device removal, GGUF quantization support, speculative decoding, dynamic batching, and support for Llama/Qwen/DeepSeek-class models. Its commands require each rank to point at the same GGUF file, and the README shows ring communication across ranks.
|
||||||
|
|
||||||
|
What it solves:
|
||||||
|
|
||||||
|
- llama.cpp-derived GGUF distributed execution
|
||||||
|
- heterogeneous device scheduling
|
||||||
|
- low memory pressure via mmap/page cache behavior
|
||||||
|
- disk prefetch as a first-class performance dimension
|
||||||
|
- ring communication for home clusters
|
||||||
|
- GGUF quantization support
|
||||||
|
|
||||||
|
What it does not directly solve for us:
|
||||||
|
|
||||||
|
- public volunteer marketplace
|
||||||
|
- torrent artifact discovery and seeding economics
|
||||||
|
- tracker-injected route over internet/NAT/relay
|
||||||
|
- per-node independent shard selection and rewards
|
||||||
|
- GLM-5.2 support is not established from the README
|
||||||
|
|
||||||
|
Design import:
|
||||||
|
|
||||||
|
- Study mmap and prefetching before inventing partial GGUF loading.
|
||||||
|
- Include disk speed and memory pressure in routing.
|
||||||
|
- Heterogeneity-aware scheduling is mandatory.
|
||||||
|
- Weak nodes should be excluded from a route if they slow the whole decode path.
|
||||||
|
|
||||||
|
## llama.cpp / GGUF
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
|
||||||
|
- <https://github.com/ggml-org/llama.cpp>
|
||||||
|
- <https://raw.githubusercontent.com/ggml-org/llama.cpp/master/tools/gguf-split/README.md>
|
||||||
|
- <https://raw.githubusercontent.com/ggml-org/llama.cpp/master/ggml/CMakeLists.txt>
|
||||||
|
|
||||||
|
llama.cpp is the performance runtime we want for GGUF. It supports local GGUF loading, many CPU/GPU backends, OpenAI-compatible serving, quantization, and `gguf-split` can split or merge GGUF files by max size or tensor count. The ggml build options include many hardware backends and RPC support.
|
||||||
|
|
||||||
|
What it solves:
|
||||||
|
|
||||||
|
- mature CPU/GPU local inference
|
||||||
|
- GGUF ecosystem
|
||||||
|
- quantized weights
|
||||||
|
- local OpenAI-compatible server
|
||||||
|
- split/merge tooling for artifact distribution
|
||||||
|
|
||||||
|
What it does not solve by itself:
|
||||||
|
|
||||||
|
- torrent distribution and reward model
|
||||||
|
- per-session distributed route over arbitrary nodes
|
||||||
|
- public-node trust/fraud model
|
||||||
|
- stable API for arbitrary layer-boundary hidden-state I/O, if not already exposed
|
||||||
|
|
||||||
|
Design import:
|
||||||
|
|
||||||
|
- Use llama.cpp locally before attempting distributed GGUF.
|
||||||
|
- Collaborate upstream on layer-range execution and KV ownership APIs.
|
||||||
|
- Keep GGUF split for artifacts, not as the only execution-shard definition.
|
||||||
|
|
||||||
|
## GLM-5.2
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
|
||||||
|
- <https://huggingface.co/zai-org/GLM-5.2>
|
||||||
|
- <https://huggingface.co/zai-org/GLM-5.2/blob/main/config.json>
|
||||||
|
|
||||||
|
GLM-5.2 is MIT licensed, 753B parameters, and advertises a 1M-token context. The config uses `glm_moe_dsa`, 78 layers, `hidden_size=6144`, `kv_lora_rank=512`, `qk_head_dim=256`, `qk_nope_head_dim=192`, `qk_rope_head_dim=64`, `v_head_dim=256`, and `max_position_embeddings=1048576`. The model card states IndexShare reduces per-token FLOPs at 1M context.
|
||||||
|
|
||||||
|
Design import:
|
||||||
|
|
||||||
|
- DSA/MLA-style compressed KV makes 128K feasible.
|
||||||
|
- Tracker should not need to understand DSA internals; backend should expose cache budget and compatibility metadata.
|
||||||
|
- GLM-5.2 is a later target after generic distributed KV works.
|
||||||
|
|
||||||
|
## DeepSeek-V4-Flash
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
|
||||||
|
- <https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash>
|
||||||
|
- <https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash/blob/main/config.json>
|
||||||
|
|
||||||
|
DeepSeek-V4-Flash is MIT licensed and published as `deepseek-ai/DeepSeek-V4-Flash` on Hugging Face. The model card describes DeepSeek-V4-Flash as a 284B-parameter MoE model with 13B activated parameters and a 1M-token context. Hugging Face tags it as `deepseek_v4`, Transformers, Safetensors, and FP8. The repository lists 46 safetensor shards and around 160 GB total size.
|
||||||
|
|
||||||
|
Config highlights:
|
||||||
|
|
||||||
|
- `model_type=deepseek_v4`
|
||||||
|
- `hidden_size=4096`
|
||||||
|
- `num_hidden_layers=43`
|
||||||
|
- `num_attention_heads=64`
|
||||||
|
- `num_key_value_heads=1`
|
||||||
|
- `n_routed_experts=256`
|
||||||
|
- `num_experts_per_tok=6`
|
||||||
|
- `q_lora_rank=1024`
|
||||||
|
- `o_lora_rank=1024`
|
||||||
|
- `qk_rope_head_dim=64`
|
||||||
|
- `sliding_window=128`
|
||||||
|
- `max_position_embeddings=1048576`
|
||||||
|
- `expert_dtype=fp4`
|
||||||
|
- FP8 quantization metadata
|
||||||
|
|
||||||
|
Design import:
|
||||||
|
|
||||||
|
- Good first serious large-model target after the protocol smoke test because it is much smaller than 1.6T-class models while still validating MoE, compressed attention/cache behavior, and large-context routing.
|
||||||
|
- Not the first protocol smoke model. Use a smaller, boring, llama.cpp-supported GGUF model first so route/session/KV bugs are isolated from DeepSeek-specific architecture support.
|
||||||
|
- The support audit must verify the available local runtime path: PyTorch/Transformers, vLLM/SGLang, and any GGUF/llama.cpp quantization route.
|
||||||
|
|
||||||
|
## Ornith-1.0-397B
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
|
||||||
|
- <https://huggingface.co/deepreinforce-ai/Ornith-1.0-397B>
|
||||||
|
- <https://huggingface.co/inferencerlabs/Ornith-1.0-397B-MLX-Q9>
|
||||||
|
|
||||||
|
Ornith-1.0-397B is MIT licensed, Qwen3.5-MoE based, with 397B MoE scale. Its base config shows 60 layers and a hybrid pattern where full attention appears every fourth layer, with other layers using linear attention. The MLX Q9 quantized variant is around 447 GB and reports high-quality Q9 behavior in its model card.
|
||||||
|
|
||||||
|
Design import:
|
||||||
|
|
||||||
|
- Hybrid attention can make large models more tractable than dense full-attention assumptions.
|
||||||
|
- Model-specific cache accounting is required; "params" alone is not enough to route.
|
||||||
|
|
||||||
|
## Synthesis
|
||||||
|
|
||||||
|
The prior art strongly supports the direction, but no project exactly matches the target product:
|
||||||
|
|
||||||
|
- Petals proves volunteer layer-serving is useful.
|
||||||
|
- exo proves UX/topology-aware local clusters matter.
|
||||||
|
- Distributed Llama proves CPU home clusters can cooperate but also shows root/worker constraints.
|
||||||
|
- prima.cpp proves llama.cpp/GGUF distribution across low-resource devices is plausible and that disk/mmap scheduling matters.
|
||||||
|
- llama.cpp/GGUF is the ecosystem to collaborate with for runtime performance.
|
||||||
|
- DeepSeek-V4-Flash is a plausible first serious large-model target after a small protocol smoke model.
|
||||||
|
|
||||||
|
The platform-specific work remains:
|
||||||
|
|
||||||
|
- torrent/content-addressed model artifact marketplace
|
||||||
|
- tracker-owned route selection and billing
|
||||||
|
- per-shard local KV sessions
|
||||||
|
- relay/NAT support
|
||||||
|
- fraud/reputation/audit
|
||||||
|
- OpenAI-compatible public gateway
|
||||||
412
.scratch/distributed-gguf-runtime/technical-challenges.md
Normal file
412
.scratch/distributed-gguf-runtime/technical-challenges.md
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
# Distributed GGUF Technical Challenge Register
|
||||||
|
|
||||||
|
This document focuses on the engineering problems that decide whether the distributed GGUF path is viable. The important distinction is:
|
||||||
|
|
||||||
|
- **Model artifacts move like torrents.**
|
||||||
|
- **Inference state moves like a pipeline.**
|
||||||
|
- **Hot KV state does not move unless we are explicitly checkpointing or repairing a route.**
|
||||||
|
|
||||||
|
## Current Constraint
|
||||||
|
|
||||||
|
The existing full local PyTorch path lets Transformers own generation and local KV cache.
|
||||||
|
|
||||||
|
The existing distributed PyTorch path does not. It manually calls shard layers with cache disabled and recomputes the whole growing prompt for every generated token. It passes hidden activations across shard boundaries, not KV cache, but those activations currently include the full sequence on every decode step.
|
||||||
|
|
||||||
|
For a 128K context and `hidden_size=6144`, one bfloat16 activation crossing one shard boundary is roughly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
131072 tokens * 6144 hidden * 2 bytes = 1.5 GiB
|
||||||
|
```
|
||||||
|
|
||||||
|
That is acceptable once during chunked prefill only if chunked and streamed. It is not acceptable once per generated token.
|
||||||
|
|
||||||
|
## Challenge 1: Decode Must Be O(1) Per Token Across Each Seam
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
During decode, sending `[batch, sequence, hidden]` over the network scales with context length. At 128K, the network dominates everything.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Split execution into explicit **prefill** and **decode-step** phases.
|
||||||
|
|
||||||
|
- Prefill accepts prompt chunks and builds local cache on every shard.
|
||||||
|
- Decode-step accepts exactly one new token or one-step activation.
|
||||||
|
- Every shard reads its own hot KV state, appends one position, and forwards a one-step activation.
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- A two-node route prefills a 4K prompt once.
|
||||||
|
- The next 100 generated tokens do not resend a 4K activation.
|
||||||
|
- Decode seam payload is proportional to `hidden_size`, not `context_length * hidden_size`.
|
||||||
|
|
||||||
|
## Challenge 2: Stable Route Session State
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
KV cache only works if every hop agrees that multiple calls belong to the same route session. A fresh request id per hop or per token destroys cache locality.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Introduce a route-session lifecycle.
|
||||||
|
|
||||||
|
```text
|
||||||
|
create route session
|
||||||
|
-> tracker pins inference route
|
||||||
|
-> head node assigns session_id and route_id
|
||||||
|
-> every hop allocates local cache for its layer range
|
||||||
|
-> prefill chunks append cache
|
||||||
|
-> decode steps append cache
|
||||||
|
-> close session releases cache
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimum state key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
session_id
|
||||||
|
route_id
|
||||||
|
model_preset
|
||||||
|
model_revision
|
||||||
|
backend_id
|
||||||
|
cache_abi
|
||||||
|
layer_start
|
||||||
|
layer_end
|
||||||
|
position
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- A node can report active sessions and cache bytes by session.
|
||||||
|
- Closing a session frees the per-shard cache.
|
||||||
|
- Replaying a decode-step with the wrong route/session fails before model execution.
|
||||||
|
|
||||||
|
## Challenge 3: KV Cache Ownership
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
A centralized KV cache sounds attractive for failover, but it puts remote storage in the tightest loop of generation. It also creates privacy and consistency problems.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Hot KV state is owned by the node that owns the shard for that route session.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Node A: layers 0..15 hot KV for session S
|
||||||
|
Node B: layers 16..31 hot KV for session S
|
||||||
|
Node C: layers 32..77 hot KV for session S
|
||||||
|
```
|
||||||
|
|
||||||
|
The tracker may know where active KV lives, but it does not serve it during decode.
|
||||||
|
|
||||||
|
Cache servers may store:
|
||||||
|
|
||||||
|
- prefix snapshots
|
||||||
|
- failover checkpoints
|
||||||
|
- audit samples
|
||||||
|
- cold reusable context blocks
|
||||||
|
|
||||||
|
They must not be required for every generated token.
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- Killing a cache server does not affect an active decode route.
|
||||||
|
- Killing a route node fails the route in alpha unless a compatible snapshot exists.
|
||||||
|
|
||||||
|
## Challenge 4: Prefill Is Still Large
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
Even with correct decode, prefill can move a lot of data. A 128K prompt cannot be sent as one activation blob through many shard boundaries.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Use the existing binary activation direction from ADR-0008:
|
||||||
|
|
||||||
|
- bfloat16 activation body
|
||||||
|
- shape/dtype/session metadata in headers
|
||||||
|
- zstd level 1 optional compression
|
||||||
|
- chunked prefill
|
||||||
|
- backpressure between hops
|
||||||
|
|
||||||
|
For large contexts, prefill should stream in chunks such as 128, 256, or 512 tokens. The right chunk size is a benchmark output, not a constant baked into the domain model.
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- Peak per-hop prefill memory is bounded by chunk size.
|
||||||
|
- A slow downstream node applies backpressure instead of letting upstream buffer the whole prompt.
|
||||||
|
|
||||||
|
## Challenge 5: GGUF Artifact Splits Are Not Execution Shards
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
GGUF split files can divide model data by size or tensor count. That is useful for storage and transfer, but it is not the same as a network Shard. A Shard in this project is a contiguous layer range with reward, route, and cache meaning.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Define an artifact manifest that maps storage chunks to semantic model parts.
|
||||||
|
|
||||||
|
Required concepts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
artifact_id
|
||||||
|
model_preset
|
||||||
|
upstream_repo
|
||||||
|
upstream_revision
|
||||||
|
license
|
||||||
|
runtime_backend
|
||||||
|
quantization
|
||||||
|
context_cap
|
||||||
|
file_hashes
|
||||||
|
piece_hashes
|
||||||
|
tensor_to_layer_map
|
||||||
|
layer_to_artifact_map
|
||||||
|
tokenizer_artifacts
|
||||||
|
cache_descriptor
|
||||||
|
```
|
||||||
|
|
||||||
|
The Shard Swarm seeds artifacts. The Inference Route executes shards.
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- Given `layers 16..31`, a node can compute the exact artifact pieces it must download and verify.
|
||||||
|
- Given a local artifact directory, the node can prove which layer ranges it can serve.
|
||||||
|
|
||||||
|
## Challenge 6: llama.cpp Is Optimized For Whole-Graph Execution
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
`llama-server` is excellent for local inference, but a distributed route needs lower-level capabilities:
|
||||||
|
|
||||||
|
- load selected layers/tensors or mmap them without full materialization
|
||||||
|
- accept hidden states from a previous shard
|
||||||
|
- execute only a layer range
|
||||||
|
- emit hidden states at a boundary
|
||||||
|
- own KV/state for only that layer range
|
||||||
|
- report cache layout and memory requirements
|
||||||
|
|
||||||
|
Those are not the normal public serving abstractions.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Stage the llama.cpp path instead of jumping directly to internet-scale distributed GGUF.
|
||||||
|
|
||||||
|
1. Use llama.cpp as a full local GGUF backend for immediate CPU performance.
|
||||||
|
2. Build a localhost layer-boundary prototype on a simple supported GGUF model.
|
||||||
|
3. Identify the minimal `libllama`/ggml hooks needed for layer-range execution.
|
||||||
|
4. Collaborate upstream on a stable extension rather than carrying a long-lived fork.
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- Process A runs layers `0..k`, exports hidden states.
|
||||||
|
- Process B imports those hidden states, runs `k+1..n`, and produces logits close to full single-process execution.
|
||||||
|
- Both processes maintain only their own cache state.
|
||||||
|
|
||||||
|
## Challenge 7: Model Architectures Are Not Uniform
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
Dense Llama-style attention, MoE, MLA/DSA, and hybrid linear/full attention do not have the same cache shape, routing cost, or layer cost.
|
||||||
|
|
||||||
|
GLM-5.2 uses compressed DSA/MLA-style state. Ornith uses a hybrid attention pattern. Parameter count alone is a poor routing metric.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Keep model-specific cache internals inside the backend. The tracker should route based on backend-advertised capabilities and measured telemetry, not on hardcoded tensor formulas.
|
||||||
|
|
||||||
|
Backend capability report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
model_arch
|
||||||
|
supported_runtime
|
||||||
|
supports_prefill
|
||||||
|
supports_decode_step
|
||||||
|
supports_layer_range
|
||||||
|
supports_partial_artifacts
|
||||||
|
cache_abi
|
||||||
|
cache_bytes_per_token_estimate
|
||||||
|
prefill_tokens_per_second
|
||||||
|
decode_tokens_per_second
|
||||||
|
active_memory_floor
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- The tracker can reject a route because one node lacks the required cache ABI.
|
||||||
|
- A model support audit can say "artifact available, local full inference works, distributed layer-boundary unsupported" without ambiguity.
|
||||||
|
|
||||||
|
## Challenge 8: Tensor Parallelism Is Not The Same Product
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
Projects like Distributed Llama and prima.cpp lean toward local-cluster tensor/ring parallelism. That can work on a trusted LAN, but it usually requires tight synchronization every layer. On a public internet volunteer route, that becomes fragile and hard to reward.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
For the public network, make a Shard a contiguous layer range. Tensor parallelism can exist inside one node, one trusted colocated pod, or one future "composite node", but not as the first public routing primitive.
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- A public route can be represented as ordered layer coverage.
|
||||||
|
- Billing can attribute work to layer ranges.
|
||||||
|
- No cross-node all-reduce is required on every layer for v1.
|
||||||
|
|
||||||
|
## Challenge 9: Heterogeneity And Stragglers
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
A route is only as fast as its slowest hop during decode. A weak node holding a bottleneck shard can make a 1.6T model technically available but unusable.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Route selection must use measured telemetry, not static declarations.
|
||||||
|
|
||||||
|
Metrics:
|
||||||
|
|
||||||
|
- prefill throughput
|
||||||
|
- decode throughput
|
||||||
|
- queue depth
|
||||||
|
- disk read rate
|
||||||
|
- memory pressure
|
||||||
|
- network latency to neighbors
|
||||||
|
- route failure rate
|
||||||
|
- cache warmth
|
||||||
|
|
||||||
|
The tracker should prefer complete routes that avoid weak nodes, and the rebalancer should increase redundancy for bottleneck layer ranges.
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- A slow node is removed from a candidate route even if it has the needed layer range.
|
||||||
|
- The coverage map can show "covered but under-provisioned" separately from "coverage gap".
|
||||||
|
|
||||||
|
## Challenge 10: Reliability And Failover
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
If hot KV is local, route repair is not free. A replacement node cannot continue decoding unless it has compatible cache state.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Alpha behavior should be simple:
|
||||||
|
|
||||||
|
- route failure during prefill: fail and retry from scratch
|
||||||
|
- route failure during decode: fail unless compatible snapshot exists
|
||||||
|
- tracker restart: active sessions may be lost
|
||||||
|
- node restart: local hot KV is lost
|
||||||
|
- client-visible telemetry reports the last known phase and failure reason
|
||||||
|
|
||||||
|
Later behavior:
|
||||||
|
|
||||||
|
- periodic prefix snapshots
|
||||||
|
- snapshot generation ids
|
||||||
|
- cache ABI compatibility checks
|
||||||
|
- route repair only when the replacement node has the same model revision, layer range, backend cache ABI, and snapshot position
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- Failures produce explicit route-session errors.
|
||||||
|
- No node silently continues from missing or incompatible cache state.
|
||||||
|
|
||||||
|
## Challenge 11: Privacy, Fraud, And Audit
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
Hidden activations and KV state can leak information. Public volunteer inference is not private by default. Also, a node can return bad activations while still appearing available.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Separate product modes:
|
||||||
|
|
||||||
|
- public swarm: low privacy, broad access, audited
|
||||||
|
- private swarm: trusted nodes, stronger privacy expectation
|
||||||
|
- paid trusted route: selected nodes with stronger guarantees
|
||||||
|
|
||||||
|
Use existing validation-event and slash-proof concepts for audit, but adapt them to distributed routes:
|
||||||
|
|
||||||
|
- record model preset, route, node wallets, prompt metadata, output, and sampling seed
|
||||||
|
- sample full-route replays where feasible
|
||||||
|
- compare output/logits within model-specific tolerance
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- A client can choose public or private route policy.
|
||||||
|
- A validation event contains enough information to reproduce route membership and observed output.
|
||||||
|
|
||||||
|
## Challenge 12: Economics Must Not Reward The Wrong Bottleneck
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
Layer count, parameter count, active MoE experts, cache memory, disk serving, and network transfer are different costs. A naive equal split across nodes will be wrong.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Start with simple compute accounting:
|
||||||
|
|
||||||
|
```text
|
||||||
|
node_reward_weight =
|
||||||
|
owned_layer_work
|
||||||
|
* prefill_tokens
|
||||||
|
+ owned_layer_work
|
||||||
|
* decode_tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
Then refine with:
|
||||||
|
|
||||||
|
- measured throughput
|
||||||
|
- active MoE cost
|
||||||
|
- storage/seeding contribution
|
||||||
|
- cache memory reservation
|
||||||
|
- reliability
|
||||||
|
|
||||||
|
Keep artifact seeding rewards separate from inference rewards until fraud and metering are clear.
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- A node that only seeds artifacts is not paid as if it executed inference.
|
||||||
|
- A node that executes a heavier shard can earn more than a node executing a light shard.
|
||||||
|
|
||||||
|
## Challenge 13: Long Requests Need Streaming Or Realtime Feedback
|
||||||
|
|
||||||
|
Problem:
|
||||||
|
|
||||||
|
Large distributed routes may spend meaningful time in artifact loading, prefill, queueing, or slow decode. The product can tolerate latency, but users should not wait blindly.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
|
||||||
|
Streaming token deltas is preferred when the backend and client transport support it. Generation Telemetry is required regardless of whether token deltas are streamed.
|
||||||
|
|
||||||
|
Minimum telemetry:
|
||||||
|
|
||||||
|
```text
|
||||||
|
session_id
|
||||||
|
route_id
|
||||||
|
model_preset
|
||||||
|
phase = queued | loading | prefill | decode | finalizing | failed
|
||||||
|
prefill_tokens_done
|
||||||
|
prefill_tokens_total
|
||||||
|
generated_tokens
|
||||||
|
rolling_tokens_per_second
|
||||||
|
average_tokens_per_second
|
||||||
|
active_route_nodes
|
||||||
|
failure_reason
|
||||||
|
```
|
||||||
|
|
||||||
|
The gateway may expose token deltas and telemetry through Server-Sent Events or WebSocket. Simple clients may use a polling endpoint for telemetry and receive the final answer only when complete.
|
||||||
|
|
||||||
|
Acceptance test:
|
||||||
|
|
||||||
|
- A client can show live progress before the first output token is available.
|
||||||
|
- During decode, the user sees streamed token deltas when supported.
|
||||||
|
- During decode, the user sees rolling tokens/sec even if output text is not streamed.
|
||||||
|
- A failed route returns a final error and the last known phase/reason.
|
||||||
|
|
||||||
|
## Engineering Order
|
||||||
|
|
||||||
|
1. Fix distributed PyTorch cache semantics first. This proves the route-session model without llama.cpp internals.
|
||||||
|
2. Add local full-model llama.cpp/GGUF serving for immediate CPU improvement.
|
||||||
|
3. Add Generation Telemetry for route sessions so long requests are observable.
|
||||||
|
4. Preserve binary HTTP activation transfer while splitting prefill/decode and measuring payload sizes.
|
||||||
|
5. Add artifact manifest and Shard Swarm metadata.
|
||||||
|
6. Prototype llama.cpp layer-boundary execution locally.
|
||||||
|
7. Network the GGUF route only after the cache/session protocol works.
|
||||||
|
8. Audit DeepSeek-V4-Flash as the first serious large-model target.
|
||||||
|
9. Audit GLM-5.2 and Ornith support after simpler GGUF models pass the route test.
|
||||||
32
CONTEXT.md
32
CONTEXT.md
@@ -22,10 +22,38 @@ _Avoid_: torrent, cluster, pool
|
|||||||
An ordered sequence of nodes whose shards together cover all layers of a model. The tracker selects the optimal route per request.
|
An ordered sequence of nodes whose shards together cover all layers of a model. The tracker selects the optimal route per request.
|
||||||
_Avoid_: pipeline, chain, path
|
_Avoid_: pipeline, chain, path
|
||||||
|
|
||||||
|
**Route Session**:
|
||||||
|
An active inference request bound to one Inference Route and one stable session id while the request is being served.
|
||||||
|
_Avoid_: conversation, job, token stream
|
||||||
|
|
||||||
|
**Activation Seam**:
|
||||||
|
The boundary between two adjacent shard executions where hidden states pass from one node to the next.
|
||||||
|
_Avoid_: handoff, layer gap, boundary hop
|
||||||
|
|
||||||
|
**Hot KV State**:
|
||||||
|
The live attention/cache state a node holds for its own shard during a Route Session.
|
||||||
|
_Avoid_: centralized KV cache, global cache, remote cache
|
||||||
|
|
||||||
|
**Prefix Snapshot**:
|
||||||
|
A persisted copy of reusable Route Session state for a prompt prefix, used for reuse, retry, or failover.
|
||||||
|
_Avoid_: hot cache, CDN cache, active KV
|
||||||
|
|
||||||
|
**Model Artifact**:
|
||||||
|
A versioned model file or tokenizer file that nodes download, verify, and keep locally to serve a Model Preset.
|
||||||
|
_Avoid_: model blob, weights dump, asset
|
||||||
|
|
||||||
|
**Artifact Manifest**:
|
||||||
|
The canonical record that identifies the Model Artifacts, their integrity checks, and the model parts they support.
|
||||||
|
_Avoid_: torrent file, metadata JSON, download list
|
||||||
|
|
||||||
**Gateway**:
|
**Gateway**:
|
||||||
The network entry point that accepts client requests (OpenAI-compatible HTTP), selects an inference route from the tracker, and streams results back.
|
The network entry point that accepts client requests (OpenAI-compatible HTTP), selects an inference route from the tracker, and streams results and progress to the client when possible.
|
||||||
_Avoid_: proxy, relay, orchestrator, primary
|
_Avoid_: proxy, relay, orchestrator, primary
|
||||||
|
|
||||||
|
**Generation Telemetry**:
|
||||||
|
Realtime progress information for an active Route Session, including phase, generated token count, and tokens-per-second speed.
|
||||||
|
_Avoid_: logs, debug output
|
||||||
|
|
||||||
### Tracker
|
### Tracker
|
||||||
|
|
||||||
**Tracker**:
|
**Tracker**:
|
||||||
@@ -33,7 +61,7 @@ The coordinator service that maintains the node registry, scores nodes by throug
|
|||||||
_Avoid_: coordinator, scheduler, director
|
_Avoid_: coordinator, scheduler, director
|
||||||
|
|
||||||
**Tracker Node**:
|
**Tracker Node**:
|
||||||
A node that serves at least the first-layer shard (`layers[0..k]`) for a model and acts as the inference entry point for that model. Tracker nodes own the tokenizer and `embed_tokens`, receive client requests directly, select the onward route from the coverage map, and stream results back. Any node advertising a new model to the network becomes its tracker node.
|
A node that serves at least the first-layer shard (`layers[0..k]`) for a model and acts as the inference entry point for that model. Tracker nodes own the tokenizer and `embed_tokens`, receive client requests directly, select the onward route from the coverage map, and stream results and progress when possible. Any node advertising a new model to the network becomes its tracker node.
|
||||||
_Avoid_: primary node, master node, gateway node
|
_Avoid_: primary node, master node, gateway node
|
||||||
|
|
||||||
**Coverage Map**:
|
**Coverage Map**:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ on the host firewall if other machines will join:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/meshnet-tracker start --host 0.0.0.0 --port 8080
|
.venv/bin/meshnet-tracker start --host 0.0.0.0 --port 8080
|
||||||
|
# --starting-credit 1 --devnet-topup 10
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify from the tracker host:
|
Verify from the tracker host:
|
||||||
@@ -97,6 +98,7 @@ Nodes can then join with either the LAN tracker URL or the public URL:
|
|||||||
```bash
|
```bash
|
||||||
.venv/bin/meshnet-node start --tracker http://192.168.0.179:8080 --model Qwen/Qwen2.5-0.5B-Instruct
|
.venv/bin/meshnet-node start --tracker http://192.168.0.179:8080 --model Qwen/Qwen2.5-0.5B-Instruct
|
||||||
.venv/bin/meshnet-node start --tracker https://ai.neuron.d-popov.com --model Qwen/Qwen2.5-0.5B-Instruct
|
.venv/bin/meshnet-node start --tracker https://ai.neuron.d-popov.com --model Qwen/Qwen2.5-0.5B-Instruct
|
||||||
|
.venv/bin/meshnet-node start --tracker https://ai.d-popov.com --model Qwen/Qwen2.5-0.5B-Instruct
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows / WSL2
|
### Windows / WSL2
|
||||||
@@ -514,6 +516,7 @@ Send inference through the tracker (which picks the head node and injects the ro
|
|||||||
```bash
|
```bash
|
||||||
curl -s https://ai.neuron.d-popov.com/v1/chat/completions \
|
curl -s https://ai.neuron.d-popov.com/v1/chat/completions \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer sk-mesh-<your-key>" \
|
||||||
-d '{
|
-d '{
|
||||||
"model": "Qwen/Qwen2.5-0.5B-Instruct",
|
"model": "Qwen/Qwen2.5-0.5B-Instruct",
|
||||||
"messages": [{"role": "user", "content": "What is 7 times 8?"}],
|
"messages": [{"role": "user", "content": "What is 7 times 8?"}],
|
||||||
@@ -529,6 +532,47 @@ curl -s http://localhost:7001/v1/chat/completions \
|
|||||||
-d '{"model": "Qwen/Qwen2.5-0.5B-Instruct", "messages": [{"role": "user", "content": "Hi"}]}'
|
-d '{"model": "Qwen/Qwen2.5-0.5B-Instruct", "messages": [{"role": "user", "content": "Hi"}]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Accounts, API keys, and credit (billing-enabled trackers)
|
||||||
|
|
||||||
|
Public trackers run with billing on: `/v1/chat/completions` requires a real
|
||||||
|
API key from a registered account. Unknown bearer strings get `401`; a key
|
||||||
|
with no balance gets `402 insufficient balance`.
|
||||||
|
|
||||||
|
**Dashboard flow (easiest):** open `https://<tracker>/dashboard`, register with
|
||||||
|
an email + password, then click **+ new key**. The key (`sk-mesh-…`) shows its
|
||||||
|
balance next to it. If the tracker was started with `--starting-credit`, your
|
||||||
|
first key arrives pre-funded (Caller Credit, once per account). If it was
|
||||||
|
started with `--devnet-topup`, every key row has a **+$N (devnet)** button to
|
||||||
|
refill during testing.
|
||||||
|
|
||||||
|
**Curl flow:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Register (once)
|
||||||
|
curl -s https://<tracker>/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email": "you@example.com", "password": "hunter22-or-better"}'
|
||||||
|
# → {"session_token": "...", ...}
|
||||||
|
|
||||||
|
# 2. Create an API key (session token from step 1)
|
||||||
|
curl -s https://<tracker>/v1/account/keys -X POST \
|
||||||
|
-H "Authorization: Bearer <session_token>"
|
||||||
|
# → {"api_key": "sk-mesh-...", "caller_credit_granted": true}
|
||||||
|
|
||||||
|
# 3. Check balance / usage
|
||||||
|
curl -s https://<tracker>/v1/account -H "Authorization: Bearer <session_token>"
|
||||||
|
|
||||||
|
# 4. (devnet trackers only) top up a key
|
||||||
|
curl -s https://<tracker>/v1/account/topup -X POST \
|
||||||
|
-H "Authorization: Bearer <session_token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"api_key": "sk-mesh-..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Operator side: both features default to 1 USDT (`--starting-credit` /
|
||||||
|
`--devnet-topup`). Set both to 0 on mainnet deployments — real deposits flow
|
||||||
|
through the on-chain USDT treasury watcher instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1 — Start the tracker (Terminal 1)
|
## Step 1 — Start the tracker (Terminal 1)
|
||||||
|
|||||||
@@ -5,10 +5,16 @@
|
|||||||
|
|
||||||
# node
|
# node
|
||||||
.\.venv\Scripts\python.exe -m meshnet_node.cli start --tracker http://localhost:8080 --model Qwen/Qwen2.5-0.5B-Instruct --port 7000 --debug
|
.\.venv\Scripts\python.exe -m meshnet_node.cli start --tracker http://localhost:8080 --model Qwen/Qwen2.5-0.5B-Instruct --port 7000 --debug
|
||||||
|
# works wsl
|
||||||
|
.\.venv\Scripts\python.exe -m meshnet_node.cli start --tracker http://192.168.0.179:8081 --model Qwen/Qwen2.5-0.5B-Instruct --shard-start 10 --port 7000 --debug --advertise-host 192.168.0.20
|
||||||
|
# works win ps
|
||||||
|
meshnet-node start --tracker http://192.168.0.179:8081 --model Qwen/Qwen2.5-0.5B-Instruct --shard-start 10 --quantization bfloat16
|
||||||
|
|
||||||
@win
|
#win
|
||||||
.venv/bin/meshnet-node start --tracker http://192.168.0.179:8081 --model Qwen/Qwen2.5-0.5B-Instruct --shard-start 20 --advertise-host 192.168.0.20
|
.venv/bin/meshnet-node start --tracker http://192.168.0.179:8081 --model Qwen/Qwen2.5-0.5B-Instruct --shard-start 20 --advertise-host 192.168.0.20
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.\.venv\Scripts\meshnet-node.exe start http://192.168.0.179:8081 --model-id Qwen/Qwen2.5-0.5B-Instruct --advertise-host 192.168.0.20
|
.\.venv\Scripts\meshnet-node.exe start http://192.168.0.179:8081 --model-id Qwen/Qwen2.5-0.5B-Instruct --advertise-host 192.168.0.20
|
||||||
.\.venv\Scripts\meshnet-node.exe start --tracker http://ai.neuron.d-popov.com --model-id Qwen/Qwen2.5-0.5B-Instruct --advertise-host 192.168.0.20
|
.\.venv\Scripts\meshnet-node.exe start --tracker http://ai.neuron.d-popov.com --model-id Qwen/Qwen2.5-0.5B-Instruct --advertise-host 192.168.0.20
|
||||||
|
|
||||||
@@ -20,7 +26,8 @@
|
|||||||
# linux
|
# linux
|
||||||
HF_HOME=/run/media/popov/d/DEV/models .venv/bin/meshnet-node start --model-id Qwen/Qwen2.5-0.5B-Instruct --shard-start 0 --shard-end 21 --quantization bfloat16 --tracker http://localhost:8081
|
HF_HOME=/run/media/popov/d/DEV/models .venv/bin/meshnet-node start --model-id Qwen/Qwen2.5-0.5B-Instruct --shard-start 0 --shard-end 21 --quantization bfloat16 --tracker http://localhost:8081
|
||||||
# win
|
# win
|
||||||
meshnet-node start --tracker http://ai.neuron.d-popov.com --model Qwen/Qwen2.5-0.5B-Instruct
|
meshnet-node start --tracker http://ai.neuron.d-popov.com --model Qwen/Qwen2.5-0.5B-Instruct --shard-start 10
|
||||||
|
meshnet-node start --tracker http://192.168.0.179:8081 --model Qwen/Qwen2.5-0.5B-Instruct --shard-start 10
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +39,14 @@ HF_HOME=/run/media/popov/d/DEV/models .venv/bin/meshnet-node start --m
|
|||||||
|
|
||||||
Because billing is enabled, chat calls need a Bearer key:
|
Because billing is enabled, chat calls need a Bearer key:
|
||||||
|
|
||||||
curl http://localhost:8080/v1/chat/completions `
|
curl http://192.168.0.179:8081 /v1/chat/completions `
|
||||||
-H "Content-Type: application/json" `
|
-H "Content-Type: application/json" `
|
||||||
-H "Authorization: Bearer test-key" `
|
-H "Authorization: Bearer test-key" `
|
||||||
-d '{"model":"stub-model","messages":[{"role":"user","content":"hi"}]}'
|
-d '{"model":"stub-model","messages":[{"role":"user","content":"hi"}]}'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# problems spotted
|
||||||
|
1. no benchmark at node start
|
||||||
|
2. CUDA stopped working on windows PS
|
||||||
|
3. solana/crypto does not work on linux tracker. does it still work on windows?
|
||||||
|
|||||||
BIN
accounts.sqlite
Normal file
BIN
accounts.sqlite
Normal file
Binary file not shown.
BIN
billing.sqlite
BIN
billing.sqlite
Binary file not shown.
@@ -28,6 +28,9 @@ services:
|
|||||||
PUBLIC_RELAY_URL: ${PUBLIC_RELAY_URL:-}
|
PUBLIC_RELAY_URL: ${PUBLIC_RELAY_URL:-}
|
||||||
HEARTBEAT_TIMEOUT: ${HEARTBEAT_TIMEOUT:-30}
|
HEARTBEAT_TIMEOUT: ${HEARTBEAT_TIMEOUT:-30}
|
||||||
ENABLE_BILLING_DB: ${ENABLE_BILLING_DB:-1}
|
ENABLE_BILLING_DB: ${ENABLE_BILLING_DB:-1}
|
||||||
|
# Devnet-friendly defaults (US-039/040): set both to 0 on mainnet.
|
||||||
|
STARTING_CREDIT: ${STARTING_CREDIT:-1}
|
||||||
|
DEVNET_TOPUP: ${DEVNET_TOPUP:-1}
|
||||||
SOLANA_RPC_URL: ${SOLANA_RPC_URL:-}
|
SOLANA_RPC_URL: ${SOLANA_RPC_URL:-}
|
||||||
USDT_MINT: ${USDT_MINT:-}
|
USDT_MINT: ${USDT_MINT:-}
|
||||||
MESHNET_TREASURY_KEYPAIR_B64: ${MESHNET_TREASURY_KEYPAIR_B64:-}
|
MESHNET_TREASURY_KEYPAIR_B64: ${MESHNET_TREASURY_KEYPAIR_B64:-}
|
||||||
@@ -66,6 +69,8 @@ services:
|
|||||||
BILLING_ARGS=""
|
BILLING_ARGS=""
|
||||||
if [ "$${ENABLE_BILLING_DB:-1}" = "1" ]; then
|
if [ "$${ENABLE_BILLING_DB:-1}" = "1" ]; then
|
||||||
BILLING_ARGS="--billing-db /var/lib/meshnet/billing.sqlite"
|
BILLING_ARGS="--billing-db /var/lib/meshnet/billing.sqlite"
|
||||||
|
else
|
||||||
|
BILLING_ARGS="--no-billing"
|
||||||
fi
|
fi
|
||||||
TREASURY_ARGS=""
|
TREASURY_ARGS=""
|
||||||
if [ -n "$${SOLANA_RPC_URL:-}" ] || [ -n "$${USDT_MINT:-}" ] || [ -n "$${MESHNET_TREASURY_KEYPAIR_B64:-}" ]; then
|
if [ -n "$${SOLANA_RPC_URL:-}" ] || [ -n "$${USDT_MINT:-}" ] || [ -n "$${MESHNET_TREASURY_KEYPAIR_B64:-}" ]; then
|
||||||
@@ -84,6 +89,9 @@ services:
|
|||||||
--self-url "$${PUBLIC_TRACKER_URL}" \
|
--self-url "$${PUBLIC_TRACKER_URL}" \
|
||||||
--relay-url "$${RELAY_URL}" \
|
--relay-url "$${RELAY_URL}" \
|
||||||
--stats-db /var/lib/meshnet/tracker-stats.sqlite \
|
--stats-db /var/lib/meshnet/tracker-stats.sqlite \
|
||||||
|
--accounts-db /var/lib/meshnet/accounts.sqlite \
|
||||||
|
--starting-credit "$${STARTING_CREDIT:-1}" \
|
||||||
|
--devnet-topup "$${DEVNET_TOPUP:-1}" \
|
||||||
$${BILLING_ARGS} \
|
$${BILLING_ARGS} \
|
||||||
$${TREASURY_ARGS} \
|
$${TREASURY_ARGS} \
|
||||||
$${PEER_ARGS}
|
$${PEER_ARGS}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ services:
|
|||||||
BILLING_ARGS=""
|
BILLING_ARGS=""
|
||||||
if [ "$${ENABLE_BILLING_DB:-1}" = "1" ]; then
|
if [ "$${ENABLE_BILLING_DB:-1}" = "1" ]; then
|
||||||
BILLING_ARGS="--billing-db /var/lib/meshnet/billing.sqlite"
|
BILLING_ARGS="--billing-db /var/lib/meshnet/billing.sqlite"
|
||||||
|
else
|
||||||
|
BILLING_ARGS="--no-billing"
|
||||||
fi
|
fi
|
||||||
TREASURY_ARGS=""
|
TREASURY_ARGS=""
|
||||||
if [ -n "$${SOLANA_RPC_URL:-}" ] || [ -n "$${USDT_MINT:-}" ] || [ -n "$${MESHNET_TREASURY_KEYPAIR_B64:-}" ]; then
|
if [ -n "$${SOLANA_RPC_URL:-}" ] || [ -n "$${USDT_MINT:-}" ] || [ -n "$${MESHNET_TREASURY_KEYPAIR_B64:-}" ]; then
|
||||||
@@ -63,6 +65,7 @@ services:
|
|||||||
--self-url "$${PUBLIC_TRACKER_URL}" \
|
--self-url "$${PUBLIC_TRACKER_URL}" \
|
||||||
--relay-url "$${RELAY_URL}" \
|
--relay-url "$${RELAY_URL}" \
|
||||||
--stats-db /var/lib/meshnet/tracker-stats.sqlite \
|
--stats-db /var/lib/meshnet/tracker-stats.sqlite \
|
||||||
|
--accounts-db /var/lib/meshnet/accounts.sqlite \
|
||||||
$${BILLING_ARGS} \
|
$${BILLING_ARGS} \
|
||||||
$${TREASURY_ARGS} \
|
$${TREASURY_ARGS} \
|
||||||
$${PEER_ARGS}
|
$${PEER_ARGS}
|
||||||
|
|||||||
@@ -43,13 +43,27 @@ ENABLE_BILLING_DB=1
|
|||||||
```
|
```
|
||||||
|
|
||||||
For first cloud-only test, use `CLUSTER_PEERS=`. Click **Deploy the stack**.
|
For first cloud-only test, use `CLUSTER_PEERS=`. Click **Deploy the stack**.
|
||||||
|
(`ai.neuron.d-popov.com` is publicly reachable, so two-tracker sync works over the
|
||||||
|
internet — but add it only after the cloud-only friends test proves out; two-peer
|
||||||
|
Raft adds moving parts without fault tolerance.)
|
||||||
|
|
||||||
`ENABLE_BILLING_DB=1` makes billing public behavior active: `/v1/chat/completions`
|
`ENABLE_BILLING_DB=1` makes billing public behavior active: `/v1/chat/completions`
|
||||||
requires `Authorization: Bearer <api-key>`. Any key is accepted and starts with
|
requires `Authorization: Bearer <sk-mesh-...>` — a real API key created through
|
||||||
the configured starter credit; calls return `402` after that balance is
|
an account (register on `/dashboard`, then "+ new key"); arbitrary bearer
|
||||||
|
strings are rejected with `401`. Calls return `402` once the key's balance is
|
||||||
exhausted. Set `ENABLE_BILLING_DB=0` if existing unauthenticated clients must
|
exhausted. Set `ENABLE_BILLING_DB=0` if existing unauthenticated clients must
|
||||||
keep working during the first redeploy.
|
keep working during the first redeploy.
|
||||||
|
|
||||||
|
Credit variables (US-039/US-040):
|
||||||
|
|
||||||
|
```text
|
||||||
|
STARTING_CREDIT=1 # one-time Caller Credit (USDT) on an account's first key
|
||||||
|
DEVNET_TOPUP=1 # dashboard "+$N (devnet)" faucet button; 0 disables
|
||||||
|
```
|
||||||
|
|
||||||
|
Both default to **1** (devnet-friendly alpha). On any deployment holding a
|
||||||
|
mainnet treasury set both to 0 — the faucet mints client balance for free.
|
||||||
|
|
||||||
Optional Solana treasury settlement variables:
|
Optional Solana treasury settlement variables:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -94,7 +108,9 @@ Custom locations on the same proxy host:
|
|||||||
|
|
||||||
Leave sub-folder forwarding empty.
|
Leave sub-folder forwarding empty.
|
||||||
|
|
||||||
If WebSockets fail, Advanced:
|
**Required:** NPM custom locations do NOT inherit the proxy host's "Websockets
|
||||||
|
Support" toggle. Paste this into the **Advanced** box of *each* custom location
|
||||||
|
(`/ws` and `/rpc`), or every WebSocket handshake to the relay dies at nginx:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# TAI token: revenue-backed rewards for nodes, USDT/SOL payments for clients
|
# TAI token: revenue-backed rewards for nodes, USDT/SOL payments for clients
|
||||||
|
|
||||||
|
## Status: Accepted (settlement mechanics superseded by ADR-0015)
|
||||||
|
|
||||||
|
> **Settlement update (2026-07-04):** Alpha and near-term production use **USDT-direct custodial settlement** with pending-balance forfeiture penalties (ADR-0015). TAI emission, backing-price buyback, and stake deposits described below remain the **roadmap tokenomics** — not the live payment path. When TAI ships, it will mint from accumulated protocol cut; this ADR's incentive structure still governs long-term design.
|
||||||
|
|
||||||
## Core principle
|
## Core principle
|
||||||
|
|
||||||
Nodes must get paid reliably for inference work. This is the primary growth engine. All tokenomics decisions serve this goal first. The model is intentionally simple and sound — parameters can be tweaked later, but the structure must have no fatal flaws from day one.
|
Nodes must get paid reliably for inference work. This is the primary growth engine. All tokenomics decisions serve this goal first. The model is intentionally simple and sound — parameters can be tweaked later, but the structure must have no fatal flaws from day one.
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ The reference implementation (Petals) solves this with libp2p — GossipSub for
|
|||||||
|
|
||||||
### 1. TLS everywhere
|
### 1. TLS everywhere
|
||||||
|
|
||||||
All HTTP between nodes, tracker, and gateway uses HTTPS (TLS 1.3). Self-signed certificates are auto-generated on first node start and stored in `~/.config/meshnet/`. The certificate fingerprint is included in every heartbeat and gossip envelope. Nodes use TOFU (trust on first use) — they accept a peer's cert on first contact and pin the fingerprint; connections from the same peer with a different fingerprint are rejected.
|
**Intent:** All HTTP between nodes, tracker, and gateway uses HTTPS (TLS 1.3). Self-signed certificates are auto-generated on first node start and stored in `~/.config/meshnet/`. The certificate fingerprint is included in every heartbeat and gossip envelope. Nodes use TOFU (trust on first use) — they accept a peer's cert on first contact and pin the fingerprint; connections from the same peer with a different fingerprint are rejected.
|
||||||
|
|
||||||
The relay node uses a real CA-signed certificate (Let's Encrypt) because it is the internet-facing bootstrap point.
|
The relay node uses a real CA-signed certificate (Let's Encrypt) because it is the internet-facing bootstrap point.
|
||||||
|
|
||||||
|
> **Alpha reality correction (2026-07-04, ADR-0016):** As implemented, **TLS applies to the relay path** (`packages/relay`, WSS circuit proxy) and P2P packages that generate certs — **not** to plain HTTP on tracker ↔ node inference and registration in typical LAN/dev deployments. Tracker registration already requires `https://` URLs for production trackers, but node-to-tracker proxy traffic may still be plaintext on alpha. Full end-to-end TLS remains the target; alpha documents this as a **known limitation**, not a silent guarantee. Hive gossip auth (ADR-0017) is the immediate mitigation for tracker replication integrity.
|
||||||
|
|
||||||
### 2. mDNS for LAN peer discovery
|
### 2. mDNS for LAN peer discovery
|
||||||
|
|
||||||
Python `zeroconf` library. Service type: `_meshnet._tcp.local.`. A node announces itself on startup and browses for existing peers. This is zero-config discovery for home and lab networks. mDNS does not traverse routers, which is correct — LAN discovery should not bleed into the internet.
|
Python `zeroconf` library. Service type: `_meshnet._tcp.local.`. A node announces itself on startup and browses for existing peers. This is zero-config discovery for home and lab networks. mDNS does not traverse routers, which is correct — LAN discovery should not bleed into the internet.
|
||||||
|
|||||||
52
docs/adr/0016-alpha-scope-and-known-limitations.md
Normal file
52
docs/adr/0016-alpha-scope-and-known-limitations.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# ADR-0016: Alpha scope and known limitations
|
||||||
|
|
||||||
|
## Status: Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Pre-release audits found the routing layer is solid (ADR-0013) but the money and trust paths are not alpha-ready: unauthenticated gossip, free starting credit, node-reported accounting, ephemeral strike/ban state, and plaintext tracker HTTP undermine release. The owner locked an **alpha scope** that ships real inference and devnet settlement while explicitly deferring multi-tracker trustlessness and cryptographic verification.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Single settlement tracker
|
||||||
|
|
||||||
|
One operator-designated tracker holds the treasury keypair and runs the settlement loop (ADR-0015). Third-party trackers may join the hive for routing and state replication but **never** sign payouts. Raft elects a leader for shard-assignment commands only; settlement leadership is operator-configured, not democratic.
|
||||||
|
|
||||||
|
### 2. Open node join
|
||||||
|
|
||||||
|
Any wallet may register a node without invite, stake deposit, or hardware attestation. Anti-sybil cost comes from probation (first N jobs unpaid), carried-forward reputation, and fraud penalties — not from closed admission.
|
||||||
|
|
||||||
|
### 3. Devnet mock-USDT
|
||||||
|
|
||||||
|
Development and alpha target **Solana devnet** with a self-created mock-USDT SPL mint (ADR-0015). Mainnet USDT is a config change, not an architecture change. TAI settlement (ADR-0002) remains deferred; the 10% protocol cut accumulates as future TAI liquidity.
|
||||||
|
|
||||||
|
### 4. Reputation carries forward
|
||||||
|
|
||||||
|
Strike count, ban status, graduated reputation, and audit history **persist across tracker restarts** and are intended to survive alpha → mainnet cutover. A banned operator cannot evade penalties by restarting the tracker or re-registering under a fresh node id with the same wallet.
|
||||||
|
|
||||||
|
### 5. Known alpha limitations (explicit, not bugs)
|
||||||
|
|
||||||
|
| Limitation | Alpha posture | Post-alpha path |
|
||||||
|
|---|---|---|
|
||||||
|
| Tracker is trusted referee for audits and blame | Acceptable — see ADR-0018 | Decentralized verifier market; Verde-style on-chain games |
|
||||||
|
| Plaintext HTTP on tracker/node paths | Acceptable on LAN/dev; relay TLS only (ADR-0010 amended) | Full TLS + cert pinning per ADR-0010 intent |
|
||||||
|
| Unauthenticated gossip replication | **Blocker** — fix before alpha (ADR-0017) | HMAC/mTLS between hive members |
|
||||||
|
| zkML / GPU TEE verification | Roadmap-only | ADR-0018 §9; re-evaluate yearly |
|
||||||
|
| Multi-tracker money-path safety | Design now (ADR-0019), implement later | Consensus-gated settlement, idempotent payouts |
|
||||||
|
| Bitwise output equality across GPUs | Not required — logit/activation tolerance (TOPLOC) | Optional Gensyn RepOps tier for referee |
|
||||||
|
|
||||||
|
### 6. Fraud must be bounded
|
||||||
|
|
||||||
|
Because reputation carries forward, correctness fraud (bad outputs) and accounting fraud (inflated tokens/shard spans) must be **economically irrational** at the locked ~5% audit budget. Detection, blame, and penalty design are ADR-0018; implementation is Bucket 1 in `.scratch/alpha-hardening/`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Alpha release criteria are Bucket 1 blockers in `.scratch/alpha-hardening/README.md`, not "all 35 user stories done."
|
||||||
|
- Documentation and runbooks must state trust assumptions plainly — custodial treasury, trusted tracker, devnet funds.
|
||||||
|
- Bucket 2 (multi-tracker) issues are tracked but not alpha-blocking.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Supersedes the spirit of prototype-only trust in ADR-0003 for production alpha behavior — see ADR-0018.
|
||||||
|
- Settlement mechanics: ADR-0015.
|
||||||
|
- Research grounding: `.scratch/alpha-hardening/research-verifiable-inference.md`.
|
||||||
75
docs/adr/0017-tracker-authentication-and-authorization.md
Normal file
75
docs/adr/0017-tracker-authentication-and-authorization.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# ADR-0017: Tracker authentication and authorization
|
||||||
|
|
||||||
|
## Status: Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The tracker exposes three overlapping trust domains:
|
||||||
|
|
||||||
|
1. **Client API access** — Bearer API keys for inference and billing (partially gated today).
|
||||||
|
2. **Operator/admin access** — session-based accounts in `packages/tracker/meshnet_tracker/accounts.py` (registration, login, admin listing).
|
||||||
|
3. **Hive replication** — gossip endpoints that mutate billing, accounts, and stats without authentication today.
|
||||||
|
|
||||||
|
Financial and registry endpoints leak through inconsistent checks: some handlers require only a non-empty `Authorization` header (header-presence stub), while gossip accepts arbitrary peer events. Alpha requires a **single auth boundary** before any state mutation or privileged read.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Account subsystem (`accounts.py`)
|
||||||
|
|
||||||
|
- First registered account is **admin**; subsequent accounts are `user`.
|
||||||
|
- Login identifiers: email **or** wallet address + PBKDF2-SHA256 password.
|
||||||
|
- API keys (`sk-mesh-…`) are scoped to an account; revocation is event-sourced and gossip-replicated.
|
||||||
|
- **Sessions are local** (bearer tokens in memory, 7-day TTL) — not replicated across the hive. Each tracker validates its own sessions.
|
||||||
|
- Account mutations persist to SQLite and replicate via `/v1/accounts/gossip` events.
|
||||||
|
|
||||||
|
### 2. Client inference auth
|
||||||
|
|
||||||
|
- `POST /v1/chat/completions` requires a valid, non-revoked API key and positive ledger balance (402 if broke).
|
||||||
|
- **Starting credit is 0** for new API keys (ADR-0016 / issue C5). No implicit faucet; clients must deposit mock-USDT via bound wallet (US-032) or admin credit.
|
||||||
|
|
||||||
|
### 3. Gossip auth (alpha blocker)
|
||||||
|
|
||||||
|
All hive mutation endpoints require **authenticated peer identity**:
|
||||||
|
|
||||||
|
- `/v1/billing/gossip` — billing event replication
|
||||||
|
- `/v1/accounts/gossip` — account/key replication
|
||||||
|
- `/v1/stats/gossip` — rolling RPM merge
|
||||||
|
|
||||||
|
Acceptable alpha mechanisms (pick one in implementation):
|
||||||
|
|
||||||
|
- **Shared hive secret** (HMAC over request body + timestamp, configured on all trackers), or
|
||||||
|
- **Mutual TLS** between tracker peers (extends ADR-0010 relay TLS pattern).
|
||||||
|
|
||||||
|
Followers must reject events from unauthenticated callers. Read-only endpoints (`/dashboard`, `/v1/stats` GET) may remain public on LAN alpha deployments.
|
||||||
|
|
||||||
|
**Out of scope (alpha):** `POST /v1/gossip` — node throughput / peer fan-out gossip (`server.py` ~1331), distinct from hive mutation endpoints above. Document as **unauthenticated alpha limitation**; authenticate in a future ADR amendment when node identity binding exists (issue 01 tracks hive endpoints only).
|
||||||
|
|
||||||
|
### 4. Privileged operator endpoints
|
||||||
|
|
||||||
|
These require **real** auth — not header presence alone:
|
||||||
|
|
||||||
|
| Endpoint | Required role |
|
||||||
|
|---|---|
|
||||||
|
| `POST /v1/billing/forfeit` | Validator service identity or admin session |
|
||||||
|
| `POST /v1/benchmark/hop-penalty` | Admin session or service token |
|
||||||
|
| `GET /v1/benchmark/results` | Admin session or service token |
|
||||||
|
| `GET /v1/admin/accounts` | Admin session |
|
||||||
|
| `GET /v1/billing/summary`, `/v1/billing/settlements`, `/v1/registry/wallets` | Admin session (alpha: restrict financial reads) |
|
||||||
|
|
||||||
|
The unified auth middleware resolves: API key → account; session token → account + role; service token → validator; hive HMAC → peer.
|
||||||
|
|
||||||
|
### 5. Node registration
|
||||||
|
|
||||||
|
Node `POST /v1/register` is open (ADR-0016) but **banned wallets are rejected** at registration and excluded from routing. Wallet binding for client deposits (`POST /v1/wallet/register`) requires a valid API key and cryptographic ownership proof (issue C6).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Gossip authentication (C1) and unified auth boundary (A2) are prerequisites for fraud penalties and billing fixes — an attacker can otherwise forge credits, strikes, or forfeit events.
|
||||||
|
- Session locality means admin login is per-tracker URL; document in runbooks.
|
||||||
|
- Multi-tracker auth evolution (client-signed commands) is deferred to ADR-0019.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- ADR-0016 (alpha scope)
|
||||||
|
- ADR-0015 (billing gate on inference)
|
||||||
|
- `.scratch/alpha-hardening/issues/01-c1-gossip-auth.md`, `02-a2-unified-auth-boundary.md`
|
||||||
92
docs/adr/0018-fraud-detection-verification-and-reputation.md
Normal file
92
docs/adr/0018-fraud-detection-verification-and-reputation.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# ADR-0018: Fraud detection, verification, and reputation
|
||||||
|
|
||||||
|
## Status: Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0003 established optimistic sampling with stake slashing; ADR-0015 replaced stake with **pending-balance forfeiture** as collateral. Pre-alpha audits identified two distinct fraud types:
|
||||||
|
|
||||||
|
1. **Correctness fraud** — wrong model, layer-skipping, garbage outputs.
|
||||||
|
2. **Accounting fraud** — inflated token counts or shard-span work units reported by nodes.
|
||||||
|
|
||||||
|
The validator today compares final text only and always blames the last hop (`_final_text_node` in `packages/validator/meshnet_validator/__init__.py` ~137–140) — wrong for multi-hop pipelines.
|
||||||
|
|
||||||
|
Research (`.scratch/alpha-hardening/research-verifiable-inference.md`, 2026-07-04) grounds the alpha design in deployed patterns (Prime Intellect TOPLOC, Hyperbolic PoSP, Gensyn Verde blame **patterns**). This ADR is the flagship decision record for alpha hardening.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Anchor technique: optimistic accept + teacher-forced audit
|
||||||
|
|
||||||
|
- Default audit probability **p ≈ 5%** — a **budget target**, not a hard cap. Anomalies, low reputation, and disputes escalate rate; veterans floor at ≥ 2% (research §6, §8).
|
||||||
|
- **Deterrence condition:** at p = 0.05, penalty L must exceed **L > g·(1−p)/p ≈ 19×** per-job gain g (research §1.1). **Full pending forfeiture** is the primary penalty; three strikes → ban. The ×0.8-per-strike multiplier applies to **routing/payout weight** (reputation decay), not to the forfeiture amount.
|
||||||
|
- Single-tracker alpha: the tracker (or a designated reference node) is the auditor — no verifier market, no verifier's dilemma (research §1.1).
|
||||||
|
|
||||||
|
### 2. Detection primitive: ADOPT TOPLOC
|
||||||
|
|
||||||
|
- **`pip install toploc`** (MIT, [PrimeIntellect-ai/toploc](https://github.com/PrimeIntellect-ai/toploc)) for activation fingerprint commit + verify (research build-vs-adopt table).
|
||||||
|
- Teacher-forced prefill re-verification — compare in **logit/activation space with tolerances**, never free-running token equality (research §2).
|
||||||
|
- Pin **one canonical precision/quantization** per served model; TOPLOC detects precision drift by design.
|
||||||
|
- Per-hop boundary fingerprints extend TOPLOC's final-hidden-state encoding for **multi-hop blame** (research §1.2, §8 layer 1).
|
||||||
|
|
||||||
|
### 3. Commit layer: on-demand activation commitments
|
||||||
|
|
||||||
|
- Nodes commit compact TOPLOC-style fingerprints of **output boundary activations** per hop when selected for audit (on-demand, not every request — lower serving latency; brief retention window for recent activations).
|
||||||
|
- Commitments are **audit pins, not proofs** — correctness requires independent recomputation (research §4).
|
||||||
|
|
||||||
|
### 4. Blame layer: hop-boundary bisection (adapt Verde pattern)
|
||||||
|
|
||||||
|
On audit failure:
|
||||||
|
|
||||||
|
1. Referee (tracker) teacher-forces claimed token sequence through reference model.
|
||||||
|
2. Compare committed hop-boundary fingerprints to reference at each cut-point.
|
||||||
|
3. **First divergent hop** is the culprit — fixes `_final_text_node` last-hop-only bug.
|
||||||
|
4. Full interactive Truebit/Verde on-chain game and bitwise RepOps kernels: **roadmap-only** (research §1.2, §9).
|
||||||
|
|
||||||
|
### 5. Accounting fraud: tracker-authoritative metering
|
||||||
|
|
||||||
|
- **Token counts** come from the tracker's proxied stream/non-stream response parsing (`server.py` ~1890–1943), not node self-reports.
|
||||||
|
- **Work units** derive from **tracker-assigned shard span** at route construction (`server.py` ~1776–1782), not node-declared ranges at billing time.
|
||||||
|
- See issue H2.
|
||||||
|
|
||||||
|
### 6. Reputation model (graduated, persisted)
|
||||||
|
|
||||||
|
Reputation derives **only from tracker-verified audit outcomes** + uptime/latency — never peer ratings (research §6, collusion surface).
|
||||||
|
|
||||||
|
| Signal | Effect |
|
||||||
|
|---|---|
|
||||||
|
| Clean audits | Slow reputation build; higher routing weight |
|
||||||
|
| Strike | ×0.8 routing multiplier per strike (graduated decay) |
|
||||||
|
| Failed audit | Full pending forfeiture + strike; audit rate → maximum |
|
||||||
|
| Ban (3 strikes) | Registration rejected; excluded from routes; pending never paid |
|
||||||
|
| New/low reputation | Elevated audit rate (20–30% target for newcomers) |
|
||||||
|
| Inactivity | Reputation decay |
|
||||||
|
|
||||||
|
Persist strike/ban/reputation in SQLite alongside billing (issue A1/A5). Probation (first N jobs unpaid) retained as re-entry cost.
|
||||||
|
|
||||||
|
### 7. Passive tripwires
|
||||||
|
|
||||||
|
Perplexity/repetition/truncation heuristics on all traffic raise audit rate without direct punishment (research §8 layer 5).
|
||||||
|
|
||||||
|
### 8. Roadmap-only (explicitly NOT alpha)
|
||||||
|
|
||||||
|
- zkML proofs of LLM inference (research §1.3)
|
||||||
|
- GPU TEE attestation on consumer cards (research §1.4)
|
||||||
|
- Fully trustless Verde interactive games + RepOps bitwise kernels (research §9)
|
||||||
|
- Decentralized verifier markets
|
||||||
|
- Peer-rating reputation (EigenTrust)
|
||||||
|
- PoW as correctness proof — registration-time hardware attestation only, optional (research §4)
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Validator must be rewired: TOPLOC verify, hop blame, tracker-authoritative events — not string compare on final text alone.
|
||||||
|
- Threshold calibration requires an **honest-noise corpus** across the volunteer fleet before production thresholds (research §8).
|
||||||
|
- ADR-0003 remains historical; penalty mechanics follow ADR-0015 + this ADR.
|
||||||
|
- Implementation order: auth + persistence → accounting → TOPLOC → bisection → reputation routing (`.scratch/alpha-hardening/README.md`).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Research: `.scratch/alpha-hardening/research-verifiable-inference.md` (§8 recommended scheme, §9 roadmap, build-vs-adopt table)
|
||||||
|
- ADR-0015 (forfeiture collateral)
|
||||||
|
- ADR-0016 (alpha scope)
|
||||||
|
- ADR-0017 (validator/forfeit auth)
|
||||||
|
- Issues: `06-fraud-toploc-integration.md` through `10-fraud-penalty-calibration-wiring.md`
|
||||||
66
docs/adr/0019-money-path-consistency-multi-tracker.md
Normal file
66
docs/adr/0019-money-path-consistency-multi-tracker.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# ADR-0019: Money-path consistency (multi-tracker)
|
||||||
|
|
||||||
|
## Status: Accepted (design); implementation deferred post-alpha
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0015 assumes a **single settlement tracker** with Raft-replicated ledger events. The hive already gossips billing, accounts, and stats between trackers. When multiple trackers participate, several money-path races appear:
|
||||||
|
|
||||||
|
- Settlement retries may double-pay if idempotency keys diverge from on-chain reality.
|
||||||
|
- Any tracker follower can apply `forfeit` / `charge` events — fine under one trusted operator, dangerous with adversarial peers.
|
||||||
|
- Raft term/vote state is in-memory — restart can duplicate leadership epochs.
|
||||||
|
- `forfeit` events are not commutative with `charge`/`payout` ordering under concurrent replication.
|
||||||
|
|
||||||
|
Alpha ships with **one settlement-capable tracker** (ADR-0016). This ADR records the accepted multi-tracker design so Bucket 2 issues do not block alpha while preventing silent architectural drift.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Settlement idempotency (C2)
|
||||||
|
|
||||||
|
- Every on-chain payout batch carries a **stable `settlement_id`** already debiting pending before broadcast (`server.py` ~3353–3356, `_send_settlement` ~3358).
|
||||||
|
- **Required hardening:** `solana_adapter.send_payouts` (~186–213) and the settlement loop must treat the Solana transaction signature as the idempotency anchor — retries reuse the same `settlement_id`; confirmed signatures must never spawn a second debit.
|
||||||
|
- Deposit watcher already uses `deposit-<sig>` event ids — extend the same pattern to payouts.
|
||||||
|
|
||||||
|
### 2. Consensus-gated money mutations (C3/C4)
|
||||||
|
|
||||||
|
Money-affecting events (`charge`, `payout`, `forfeit`, `credit`, `settlement`, `bind`) must commit through **Raft** (or successor consensus), not best-effort gossip alone:
|
||||||
|
|
||||||
|
- Only the Raft leader appends money commands to the replicated log.
|
||||||
|
- Followers apply committed entries in order; gossip becomes a transport, not the source of truth.
|
||||||
|
- Settlement signing remains **leader-only** and **treasury-key-bearing** (ADR-0015).
|
||||||
|
|
||||||
|
Shard-assignment commands already use Raft (`raft.py`); extend the log command set.
|
||||||
|
|
||||||
|
### 3. Durable Raft metadata (A3)
|
||||||
|
|
||||||
|
Persist `currentTerm`, `votedFor`, and log index to disk (`raft.py` ~26 — today term lives only in memory). Without this, restart can elect split leaders and duplicate settlement epochs.
|
||||||
|
|
||||||
|
### 4. Commutative forfeit semantics (H1)
|
||||||
|
|
||||||
|
Define explicit ordering rules when replicating `forfeit` vs `charge`/`payout`:
|
||||||
|
|
||||||
|
- Forfeit amount is snapshotted at event creation time (`billing.py` ~287).
|
||||||
|
- Apply forfeit **after** pending balance reflects all prior committed charges in the same epoch, or use signed pending snapshots in the event payload.
|
||||||
|
- `setdefault` on `_pending_since` (~324) and wallet bindings (~351) must not resurrect stale state on out-of-order apply — use last-writer-wins with `(term, index)` tie-break.
|
||||||
|
|
||||||
|
### 5. Alpha posture
|
||||||
|
|
||||||
|
| Item | Alpha | Post-alpha |
|
||||||
|
|---|---|---|
|
||||||
|
| Single treasury holder | Required | Optional multi-sig |
|
||||||
|
| Gossip-authenticated billing | Required (ADR-0017) | Superseded by Raft commit for money |
|
||||||
|
| On-chain idempotency | Best-effort resend | Hard guarantee (C2) |
|
||||||
|
| Consensus-gated settlement | Leader check only (~3331) | Full Raft gate (C3/C4) |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Bucket 2 issues in `.scratch/alpha-hardening/` are **tracked but not alpha-blocking**.
|
||||||
|
- Implementing C3/C4 before alpha would delay release without security benefit while only one operator runs settlement.
|
||||||
|
- When enabling third-party settlement trackers, **all four** hardening items must ship together — partial deployment is worse than single-tracker alpha.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- ADR-0015 (settlement loop, pending collateral)
|
||||||
|
- ADR-0016 (single settlement tracker for alpha)
|
||||||
|
- ADR-0017 (gossip auth prerequisite)
|
||||||
|
- Issues: `12-c2-on-chain-idempotency.md` through `15-h1-commutative-forfeit.md`
|
||||||
60
docs/dev/test-env.md
Normal file
60
docs/dev/test-env.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Test environment
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Create a venv at the repo root and install the root dev extras plus the
|
||||||
|
packages exercised by the tests you're running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -e ".[dev]"
|
||||||
|
.venv/bin/pip install -e packages/node -e packages/tracker -e packages/gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows (native, no WSL)
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\python.exe -m pip install -e ".[dev]"
|
||||||
|
.venv\Scripts\python.exe -m pip install -e packages\node -e packages\tracker -e packages\gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
`conftest.py` at the repo root adds every `packages/*` source directory to
|
||||||
|
`sys.path`, so tests can `import meshnet_node`, `import meshnet_tracker`, etc.
|
||||||
|
without an editable install. That only resolves first-party modules, though —
|
||||||
|
each package's own third-party dependencies (e.g. `cryptography` for
|
||||||
|
`meshnet_node.wallet` and `meshnet_tracker.wallet_proof`) still need to be
|
||||||
|
installed in the venv, either via `pip install -e packages/<pkg>` or by
|
||||||
|
depending on the root `dev` extra covering them.
|
||||||
|
|
||||||
|
`cryptography>=41` is declared in `packages/node/pyproject.toml` (and
|
||||||
|
`packages/p2p/pyproject.toml`) and is also pulled in by the root `dev` extra,
|
||||||
|
so `pip install -e ".[dev]"` alone is enough to run the wallet-related tests
|
||||||
|
even without installing `packages/node`.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional-dependency tests
|
||||||
|
|
||||||
|
Some tests import heavyweight or optional third-party packages and guard
|
||||||
|
themselves with `pytest.importorskip(...)` — they skip cleanly if the
|
||||||
|
dependency isn't installed:
|
||||||
|
|
||||||
|
- `tests/test_real_model_backend.py` — `torch`, `transformers`, `safetensors`,
|
||||||
|
`accelerate`, `bitsandbytes`
|
||||||
|
- `tests/test_devnet_treasury.py` — `solders`, `spl.token.instructions`
|
||||||
|
|
||||||
|
`tests/test_openai_gateway.py` is the exception: it imports `openai` and
|
||||||
|
`langchain_openai` directly inside test functions with no `importorskip`
|
||||||
|
guard, so it hard-fails if those aren't installed. They're included in the
|
||||||
|
root `dev` extra, so a normal `pip install -e ".[dev]"` covers it. If you
|
||||||
|
deliberately install a minimal environment without the `dev` extra, skip it
|
||||||
|
explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest --ignore=tests/test_openai_gateway.py
|
||||||
|
```
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Status: ready-for-agent
|
Status: superseded for alpha — see [ADR-0015](../adr/0015-usdt-custodial-settlement.md), issue [33](33-settlement-loop.md)/[34](34-forfeiture-penalty.md). ADR-0015 replaces the on-chain Anchor stake/registry/settlement contracts below with a custodial USDT treasury + off-chain ledger, and explicitly targets **Solana devnet** (with a mock-USDT SPL mint) rather than the "testnet, never devnet" rule stated here — see [ADR-0016](../adr/0016-alpha-scope-and-known-limitations.md) for the alpha scope this fits into.
|
||||||
|
|
||||||
# 06 — Solana stake + settlement contracts
|
# 06 — Solana stake + settlement contracts
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Status: ready-for-agent
|
Status: superseded for alpha — see [ADR-0018](../adr/0018-fraud-detection-verification-and-reputation.md) and `.scratch/alpha-hardening/issues/06-fraud-toploc-integration.md` through `10-fraud-penalty-calibration-wiring.md`. On-chain stake slashing replaced by pending-balance forfeiture (ADR-0015).
|
||||||
|
|
||||||
# 07 — Fraud detection: validator + on-chain slash
|
# 07 — Fraud detection: validator + on-chain slash
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Status: done
|
Status: done (core forfeiture wired); remaining fraud scope superseded — see [ADR-0018](../adr/0018-fraud-detection-verification-and-reputation.md) and `.scratch/alpha-hardening/issues/06-fraud-toploc-integration.md` through `10-fraud-penalty-calibration-wiring.md`.
|
||||||
|
|
||||||
# 34 — Hardened proof-of-work: pending-balance forfeiture penalty
|
# 34 — Hardened proof-of-work: pending-balance forfeiture penalty
|
||||||
|
|
||||||
|
|||||||
88
docs/issues/36-relay-streamed-chat.md
Normal file
88
docs/issues/36-relay-streamed-chat.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# US-036 — Streamed chat completions over the relay RPC path
|
||||||
|
|
||||||
|
Status: planned
|
||||||
|
Priority: Critical (blocks public friends-test deployment)
|
||||||
|
Stage: Designed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
With the tracker deployed on a public VPS (`cloud.neuron.d-popov.com`), every node is
|
||||||
|
behind NAT, so **every** chat request is proxied tracker → relay → head node via
|
||||||
|
`_relay_http_request` (`packages/tracker/meshnet_tracker/server.py`). That function does a
|
||||||
|
single blocking `ws.recv()` and the node's `RelayHttpBridge._handle_request` does a single
|
||||||
|
`resp.read()`. Two consequences for `stream: true` requests:
|
||||||
|
|
||||||
|
1. **No live streaming** — the client sees nothing until generation completes, then
|
||||||
|
receives the entire SSE body at once with a `Content-Length` header.
|
||||||
|
2. **Zero billing** — the tracker runs `_billable_non_stream_tokens(json.loads(body))`
|
||||||
|
on the buffered body; SSE text is not JSON, the parse fails, and the request is
|
||||||
|
billed/credited as 0 tokens. Off-LAN this silently zeroes out *all* streamed-request
|
||||||
|
accounting.
|
||||||
|
|
||||||
|
Decision (grilled 2026-07-06): implement **true multi-frame streaming** over the relay
|
||||||
|
RPC WebSocket, scoped to the tracker → head-node leg. Rejected alternatives:
|
||||||
|
billing-only SSE parse (fragile heuristic, blank-screen UX stays) and forcing
|
||||||
|
`stream:false` over the relay (exact billing but still no live tokens for testers).
|
||||||
|
Streaming through the existing SSE accounting loop fixes both symptoms with one
|
||||||
|
mechanism. Inter-node `/forward` activation hops stay single-frame (ADR-0014) — they
|
||||||
|
are one-tensor-in/one-tensor-out and gain nothing from chunking.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
A relayed response becomes a sequence of `relay-http-response` envelopes sharing one
|
||||||
|
`request_id`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// first frame — status + headers, opens the stream
|
||||||
|
{"request_id": "<id>", "status": 200, "headers": {"Content-Type": "text/event-stream"},
|
||||||
|
"stream": true, "chunk": "data: {...}\n\n", "done": false}
|
||||||
|
// zero or more continuation frames
|
||||||
|
{"request_id": "<id>", "stream": true, "chunk": "data: {...}\n\n", "done": false}
|
||||||
|
// terminal frame
|
||||||
|
{"request_id": "<id>", "stream": true, "done": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backward compatibility:** a frame with no `stream` key is a complete single response
|
||||||
|
(today's format, still used for `/forward` hops, non-SSE responses, and older nodes).
|
||||||
|
The relay and tracker treat it as terminal.
|
||||||
|
|
||||||
|
### Per component
|
||||||
|
|
||||||
|
- **Node bridge** (`packages/node/meshnet_node/relay_bridge.py`): when the local
|
||||||
|
response `Content-Type` is `text/event-stream`, read line-by-line and emit chunk
|
||||||
|
frames as lines arrive; otherwise keep the existing single-frame path (including
|
||||||
|
`body_base64` for binary). Frame sends go through the bridge's WS send lock
|
||||||
|
(US-037) so frames from concurrent requests interleave whole, never torn.
|
||||||
|
- **Relay server** (`packages/relay/meshnet_relay/server.py`): `_handle_rpc` replaces
|
||||||
|
the single `asyncio.Future` in `_pending_rpc` with a per-request `asyncio.Queue`.
|
||||||
|
Frames arriving on the target peer's gossip connection are routed by `request_id`
|
||||||
|
and forwarded to the requester WS until `done` (or a terminal legacy frame).
|
||||||
|
Timeouts: keep the 310 s overall cap; add a 120 s per-frame idle timeout so a dead
|
||||||
|
node doesn't pin the queue.
|
||||||
|
- **Tracker** (`packages/tracker/meshnet_tracker/server.py`): `_relay_http_request`
|
||||||
|
grows a streaming mode — loop `ws.recv()`; on the first frame send status/headers to
|
||||||
|
the client; write each `chunk` to the client immediately and feed it through the
|
||||||
|
same SSE token-accounting used by the direct-proxy stream loop
|
||||||
|
(`reported_stream_tokens` / `_record_observed_throughput` / `_bill_completed`), so
|
||||||
|
relayed streams bill identically to direct streams. Non-stream frames keep the
|
||||||
|
current buffered handling.
|
||||||
|
|
||||||
|
### Known limitation (accepted for alpha)
|
||||||
|
|
||||||
|
If the client disconnects mid-stream, the relay drops undeliverable frames but the
|
||||||
|
node keeps generating until completion — wasted compute bounded by one generation.
|
||||||
|
Cancellation propagation is future work.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `stream: true` chat request via relay delivers SSE chunks to the client
|
||||||
|
incrementally (test observes ≥2 distinct frame arrivals before `[DONE]`)
|
||||||
|
- Relayed streamed request records nonzero billed tokens and node work credit
|
||||||
|
- Non-streamed relayed requests and `/forward` binary hops behave exactly as before
|
||||||
|
(single frame, `body_base64` round-trip intact)
|
||||||
|
- Legacy single-frame response from an old node is accepted as terminal
|
||||||
|
- Idle stream (no frame for 120 s) returns 504 to the client and cleans up the
|
||||||
|
relay-side queue
|
||||||
|
- Extend `tests/test_gossip_and_relay.py` alongside
|
||||||
|
`test_relay_rpc_round_trips_http_request_to_peer`
|
||||||
|
- `python -m pytest` passes from repo root
|
||||||
51
docs/issues/37-relay-bridge-concurrency.md
Normal file
51
docs/issues/37-relay-bridge-concurrency.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# US-037 — Concurrent request handling in the node relay bridge
|
||||||
|
|
||||||
|
Status: planned
|
||||||
|
Priority: Critical (blocks public friends-test deployment)
|
||||||
|
Stage: Designed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`RelayHttpBridge._run` (`packages/node/meshnet_node/relay_bridge.py`) handles
|
||||||
|
`relay-http-request` envelopes serially inside its recv loop: `_handle_request` blocks
|
||||||
|
on `urllib.request.urlopen(..., timeout=300)` before the next envelope is read.
|
||||||
|
|
||||||
|
Off-LAN this is a correctness bug, not just a throughput limit: a node can be the
|
||||||
|
**head** of one inference route and a **downstream hop** of another at the same time.
|
||||||
|
While the head request occupies the bridge (up to 300 s of generation), the other
|
||||||
|
route's per-token `/forward` calls sit unread in the WebSocket buffer — overlapping
|
||||||
|
routes through a shared node are effectively broken.
|
||||||
|
|
||||||
|
Decision (grilled 2026-07-06): dispatch requests on a **bounded worker pool, default
|
||||||
|
8, configurable**. Rejected alternatives: unbounded thread-per-request (a public
|
||||||
|
deployment exposes volunteer machines to request stampedes) and reject-when-full
|
||||||
|
(bouncing `/forward` hops kills other routes' in-flight sessions; queueing beyond the
|
||||||
|
cap is today's behavior, just 8-wide).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
- Recv loop only parses envelopes and submits to a
|
||||||
|
`ThreadPoolExecutor(max_workers=N)`; workers run `_handle_request` and send the
|
||||||
|
response frame(s).
|
||||||
|
- All sends on the single relay WebSocket go through a `threading.Lock`. With US-036
|
||||||
|
a response may be multiple frames — the lock is held **per frame**, so streams from
|
||||||
|
concurrent requests interleave frame-atomically (receivers demux by `request_id`).
|
||||||
|
- `N` defaults to 8; configurable via `meshnet-node start --relay-concurrency N`
|
||||||
|
(env `MESHNET_RELAY_CONCURRENCY`). Requests beyond `N` queue in the executor.
|
||||||
|
- On reconnect (`_run`'s outer loop), in-flight workers from the dead connection may
|
||||||
|
still try to send; the send helper swallows failures on a closed socket, and the
|
||||||
|
relay side times the orphaned request out (US-036 idle timeout / existing 310 s cap).
|
||||||
|
- `stop()` shuts the executor down without waiting for stragglers (daemon threads,
|
||||||
|
same as today's bridge thread).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- While one relayed request is in flight (slow local handler), a second
|
||||||
|
`relay-http-request` to the same node completes without waiting for the first
|
||||||
|
- Responses are correctly matched by `request_id` when they complete out of order
|
||||||
|
- More than `N` simultaneous requests queue and all eventually complete; thread count
|
||||||
|
never exceeds `N` workers
|
||||||
|
- Bridge survives a relay reconnect with workers still in flight (no crash, no
|
||||||
|
deadlock; orphaned responses dropped)
|
||||||
|
- Extend `tests/test_gossip_and_relay.py`
|
||||||
|
- `python -m pytest` passes from repo root
|
||||||
62
docs/issues/38-tracker-seed-join.md
Normal file
62
docs/issues/38-tracker-seed-join.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# US-038 — Tracker cluster join via a single seed peer
|
||||||
|
|
||||||
|
Status: planned
|
||||||
|
Priority: High (blocks adding trackers without restarting the fleet)
|
||||||
|
Stage: Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Today the tracker cluster is fully static. `RaftNode` and `NodeGossip`
|
||||||
|
(`packages/tracker/meshnet_tracker/raft.py`, `gossip.py`) take a peer list at
|
||||||
|
construction and never change it; `/v1/raft/status` does not expose membership.
|
||||||
|
|
||||||
|
Consequences of starting a new tracker with only one existing peer in
|
||||||
|
`--cluster-peers`:
|
||||||
|
|
||||||
|
- Existing trackers never learn about the newcomer — they keep heartbeating and
|
||||||
|
replicating to their original list only.
|
||||||
|
- Quorum math (`(len(self.peers) + 1) / 2`) differs per tracker, so vote counts
|
||||||
|
and commit decisions disagree — the cluster silently splits rather than erroring.
|
||||||
|
|
||||||
|
Required behavior: a joining tracker is configured with **any one** live tracker
|
||||||
|
(a seed). It announces itself, the membership change replicates through the Raft
|
||||||
|
log, and every tracker — including the newcomer — converges on the same full peer
|
||||||
|
list. Removing the need to restart existing trackers when the hive grows.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **Expose membership.** `GET /v1/cluster/peers` returns
|
||||||
|
`{"self": <url>, "peers": [<url>, ...]}` (admin-safe: URLs only).
|
||||||
|
2. **Join handshake.** On startup, the joiner calls the seed's
|
||||||
|
`POST /v1/cluster/join` with its public self-url. The request is
|
||||||
|
hive-HMAC-signed (`MESHNET_HIVE_SECRET`, same fail-closed rule as gossip —
|
||||||
|
an unauthenticated join on a public tracker would let anyone enter the hive).
|
||||||
|
A non-leader seed forwards to the current leader (same pattern as
|
||||||
|
`/v1/nodes/register` forwarding).
|
||||||
|
3. **Membership through the log.** The leader appends a
|
||||||
|
`cluster-membership` entry (single-server change: add one peer per entry).
|
||||||
|
`RaftNode` gains `apply`-side handling that swaps `self.peers` — quorum is
|
||||||
|
recomputed from the applied membership, never from local config. Gossip peer
|
||||||
|
list updates from the same apply hook.
|
||||||
|
4. **Joiner bootstrap.** After a successful join response (which includes the
|
||||||
|
current peer list), the joiner sets its own peers and starts Raft as a
|
||||||
|
follower; it catches up via normal append-entries.
|
||||||
|
5. **Persistence.** Applied membership is written to the stats/state sqlite so a
|
||||||
|
restarted tracker rejoins with the last known list even if its seed is down.
|
||||||
|
6. **Config semantics.** `--cluster-peers` becomes "seed list": tried in order
|
||||||
|
until one join succeeds. A tracker with no seeds and no persisted membership
|
||||||
|
runs standalone (current single-tracker behavior unchanged).
|
||||||
|
|
||||||
|
Out of scope: peer removal/eviction (leave via operator restart for now),
|
||||||
|
joint consensus for multi-server changes, automatic seed retry after startup.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Start trackers A+B as a cluster; start C with only A as seed → within one
|
||||||
|
election timeout, A, B, and C all report the same 3-peer membership on
|
||||||
|
`GET /v1/cluster/peers`, and a value proposed on C commits on A and B
|
||||||
|
- Join without a valid hive signature is rejected with 403; join to a follower
|
||||||
|
is forwarded to the leader transparently
|
||||||
|
- Restarting C with its seed offline rejoins from persisted membership
|
||||||
|
- Standalone tracker (no seeds) behaves exactly as today
|
||||||
|
- `python -m pytest` passes from repo root
|
||||||
60
docs/issues/39-caller-credit-account-keys.md
Normal file
60
docs/issues/39-caller-credit-account-keys.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# US-039 — Caller Credit granted once per account; chat requires account keys
|
||||||
|
|
||||||
|
Status: planned
|
||||||
|
Priority: Critical (blocks friends-test inference — every request 402s)
|
||||||
|
Stage: Designed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`DEFAULT_STARTING_CREDIT = 0.0` and nothing can raise it: no CLI flag, no admin
|
||||||
|
credit endpoint. The only funding path is the on-chain USDT deposit watcher —
|
||||||
|
so on a fresh public tracker every inference request returns
|
||||||
|
`402 insufficient balance`, including keys created through the working
|
||||||
|
account flow (`/v1/auth/register` → `/v1/account/keys`, also in the dashboard).
|
||||||
|
|
||||||
|
Separately, the billing gate accepts **any** bearer string (only revoked keys
|
||||||
|
are rejected), so wiring starter credit into `BillingLedger.ensure_client`
|
||||||
|
would let anyone mint unlimited free credit by inventing keys.
|
||||||
|
|
||||||
|
Decision (grilled 2026-07-06): **Caller Credit is granted once per account**,
|
||||||
|
when the account creates its first API key; chat requests on an
|
||||||
|
accounts-enabled tracker must present a real active `sk-mesh-` key.
|
||||||
|
Rejected: credit-any-bearer (free-inference farming against volunteers' GPUs
|
||||||
|
on the open internet) and manual-admin-credit-only (operator toil, no
|
||||||
|
self-serve testing).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **`BillingLedger.grant_caller_credit(api_key, account_id, amount) -> bool`**
|
||||||
|
Appends a credit event with the deterministic id
|
||||||
|
`caller-credit-{account_id}` and note `caller-credit`. Already-seen id →
|
||||||
|
no-op returning False. The deterministic id makes the once-per-account rule
|
||||||
|
idempotent locally *and* across hive gossip (event dedupe is by id), so a
|
||||||
|
second key, a retried request, or a replicated event can never double-grant.
|
||||||
|
2. **`AccountStore.is_active_key(api_key) -> bool`.** The chat billing gate
|
||||||
|
(`_handle_proxy_chat`) rejects keys that are not active account keys with
|
||||||
|
`401 invalid_api_key` — but only when an accounts store is configured, so
|
||||||
|
embedded/test trackers without accounts keep today's any-key behavior.
|
||||||
|
3. **`--starting-credit N`** (USDT) on `meshnet-tracker start`. Default is the
|
||||||
|
single constant `DEFAULT_CALLER_CREDIT_USDT = 1.0` in `server.py`
|
||||||
|
(devnet-friendly alpha, revised 2026-07-06; set 0 on mainnet). Threaded
|
||||||
|
through `TrackerServer` → `_TrackerHTTPServer`; registration and
|
||||||
|
`_handle_account_key_create` call `grant_caller_credit` when billing is on
|
||||||
|
and the amount is positive. The ledger's own `_starting_credit` stays 0 —
|
||||||
|
implicit per-key grants in `ensure_client`/`charge_request` remain dead
|
||||||
|
code paths.
|
||||||
|
4. **Stack variable `STARTING_CREDIT`** in
|
||||||
|
`deploy/portainer/meshnet-tracker-nobuild-stack.yml` (default 1).
|
||||||
|
5. **Docs.** QUICKSTART gains "Get an API key" (register/login/create key via
|
||||||
|
dashboard or curl, what 402 means); DEPLOY_PORTAINER's stale "any key is
|
||||||
|
accepted and starts with the configured starter credit" paragraph replaced.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Fresh account → first key → key has `--starting-credit` balance; chat succeeds
|
||||||
|
- Second key on the same account → no additional credit
|
||||||
|
- Revoke-and-recreate keys → still no additional credit (deterministic event id)
|
||||||
|
- Random bearer string on an accounts-enabled tracker → 401, never 402/free work
|
||||||
|
- Tracker without accounts store: gate behavior unchanged
|
||||||
|
- `--starting-credit 0` disables the grant entirely (mainnet posture)
|
||||||
|
- `python -m pytest` passes from repo root
|
||||||
38
docs/issues/40-devnet-dashboard-topup.md
Normal file
38
docs/issues/40-devnet-dashboard-topup.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# US-040 — Devnet top-up button on the dashboard
|
||||||
|
|
||||||
|
Status: planned
|
||||||
|
Priority: High (friends-test convenience — refill without on-chain deposits)
|
||||||
|
Stage: Designed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Once Caller Credit (US-039) is spent, the only refill path is a real USDT
|
||||||
|
deposit through the treasury watcher — devnet Solana plumbing friends won't
|
||||||
|
have. For test deployments the dashboard needs a "add $10" style button.
|
||||||
|
This is a faucet: 0 disables it, and mainnet deployments must set 0.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **`--devnet-topup N`** (USDT per click) on `meshnet-tracker start`; stack
|
||||||
|
variable `DEVNET_TOPUP`. Default is the single constant
|
||||||
|
`DEFAULT_DEVNET_TOPUP_USDT = 1.0` in `server.py` (devnet-friendly alpha,
|
||||||
|
revised 2026-07-06 — the alpha's public trackers are devnet-only).
|
||||||
|
2. **`POST /v1/account/topup`** — session-authenticated (same as key
|
||||||
|
management). Body: `{"api_key": "sk-mesh-..."}`. Rules:
|
||||||
|
- 404 when the feature is disabled (flag absent/0)
|
||||||
|
- key must belong to the logged-in account (403 otherwise)
|
||||||
|
- credits exactly the configured amount (client cannot choose it),
|
||||||
|
note `devnet-topup`; returns the new balance
|
||||||
|
3. **Dashboard**: `/v1/account` response gains `topup_amount` (0 when
|
||||||
|
disabled); when positive, each key row renders a `+$N (devnet)` button
|
||||||
|
calling the endpoint.
|
||||||
|
4. **Abuse bound**: per-click amount is operator-set and requires a registered
|
||||||
|
session; acceptable for a friends-test faucet. Mainnet deployments simply
|
||||||
|
never set the flag. (Rate limiting is deliberately out of scope.)
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Flag off: endpoint 404s, dashboard shows no top-up button
|
||||||
|
- Flag on: logged-in user tops up own key, balance rises by exactly N
|
||||||
|
- Topping up another account's key → 403
|
||||||
|
- `python -m pytest` passes from repo root
|
||||||
65
docs/issues/41-account-wallet-keypair.md
Normal file
65
docs/issues/41-account-wallet-keypair.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# US-041 — Account wallet: browser-extension signing, in-browser generation, export-only
|
||||||
|
|
||||||
|
Status: planned
|
||||||
|
Priority: Medium (not needed for devnet friends test; needed before mainnet)
|
||||||
|
Stage: Designed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Registration accepts an optional `wallet` field (a pubkey string) and the
|
||||||
|
dashboard never shows it. Users need a visible wallet address on the account
|
||||||
|
panel and a path to real Solana later, without the tracker ever holding user
|
||||||
|
private keys.
|
||||||
|
|
||||||
|
Today wallets matter in two places: client deposit attribution
|
||||||
|
(`bind_wallet` → deposit watcher) and node payout identity (Pending Balance
|
||||||
|
keyed by wallet). Accounts themselves hold no keypair — and will stay that way.
|
||||||
|
|
||||||
|
## Decision (2026-07-06)
|
||||||
|
|
||||||
|
**Non-custodial.** The tracker stores public keys only; private keys never
|
||||||
|
leave the browser.
|
||||||
|
|
||||||
|
1. **External wallets via browser extension.** The dashboard integrates the
|
||||||
|
standard Solana wallet-adapter flow (Phantom, Solflare, …): connect the
|
||||||
|
extension, prove ownership by signing a nonce, and the verified pubkey is
|
||||||
|
stored as the account wallet. All future transaction authorization
|
||||||
|
(deposits; later, anything requiring a user signature) happens in the
|
||||||
|
extension — the tracker only ever sees signatures and pubkeys.
|
||||||
|
2. **New wallets generated in-browser.** For users without an extension the
|
||||||
|
dashboard generates a keypair client-side (dashboard JS, `Ed25519` via
|
||||||
|
WebCrypto or bundled tweetnacl — CSP-safe, no CDN). The pubkey is uploaded;
|
||||||
|
the private key is shown once for export (base58, plaintext acceptable for
|
||||||
|
devnet) with a "save this now" warning, and optionally kept in
|
||||||
|
localStorage for convenience on devnet.
|
||||||
|
3. **No private-key import.** Users cannot paste an existing private key into
|
||||||
|
the dashboard — accepting foreign private keys is a liability the project
|
||||||
|
refuses. Users with existing wallets use the extension path (which imports
|
||||||
|
nothing; it signs in place).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Account panel shows the account wallet address (copy button) or the two
|
||||||
|
attach flows (connect extension / generate in browser)
|
||||||
|
- `POST /v1/account/wallet` — session-authenticated; body carries pubkey and,
|
||||||
|
for the extension flow, the signed-nonce ownership proof (reuse
|
||||||
|
`wallet_proof.py` if it fits); binds the wallet to the account's keys for
|
||||||
|
deposit attribution (existing `bind_wallet` path)
|
||||||
|
- Wallet is per-account; the tracker fans the binding out to the account's
|
||||||
|
API keys internally
|
||||||
|
- Mainnet cutover remains config-only (same boundary as Mock USDT, ADR-0015)
|
||||||
|
|
||||||
|
Out of scope: password-wrapped keystore export, hardware wallets, changing
|
||||||
|
the node/miner wallet flow.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Connect-extension flow stores a verified pubkey (rejects unsigned/mismatched
|
||||||
|
nonce proofs)
|
||||||
|
- Generate flow: pubkey lands on the account; private key is never sent to the
|
||||||
|
tracker (assert no such field in the request), export works
|
||||||
|
- No endpoint or UI accepts a private key
|
||||||
|
- Deposits to the shown address credit the account's keys via the existing
|
||||||
|
watcher
|
||||||
|
- Address visible on the account panel after either flow
|
||||||
|
- `python -m pytest` passes from repo root
|
||||||
55
docs/issues/42-gguf-llamacpp-node-backend.md
Normal file
55
docs/issues/42-gguf-llamacpp-node-backend.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# US-042 — GGUF/llama.cpp node backend
|
||||||
|
|
||||||
|
Status: planned
|
||||||
|
Priority: High (unlocks big MoE models on volunteer hardware — the pool's core value)
|
||||||
|
Stage: Draft design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The node backend is transformers-only (`model_backend.py` →
|
||||||
|
`AutoModelForCausalLM`). For DeepSeek-V4-Flash (158B MoE, official weights FP8
|
||||||
|
160 GB) the only quantizations that run on consumer hardware are GGUF
|
||||||
|
(IQ2 87 GB → Q4_K_M-XL 175 GB) — llama.cpp format. The transformers-compatible
|
||||||
|
quants (FP8, NVFP4, GPTQ W4A16) all need datacenter GPUs. Volunteer machines —
|
||||||
|
including our own Strix Halo boxes (128 GB and 80 GB unified memory, GPU via
|
||||||
|
Vulkan/ROCm, no FP8 support on RDNA3.5) — run these models today only under
|
||||||
|
llama.cpp.
|
||||||
|
|
||||||
|
## Design directions to evaluate (design-it-twice)
|
||||||
|
|
||||||
|
**A. llama.cpp as a per-node shard executor.** Node loads a *layer range* of a
|
||||||
|
GGUF via llama-cpp-python; our existing hop protocol (X-Meshnet-Route,
|
||||||
|
activations over HTTP/relay) moves hidden states between nodes. Requires
|
||||||
|
llama.cpp partial-layer loading and activation import/export — investigate
|
||||||
|
feasibility first; this is the riskiest unknown.
|
||||||
|
|
||||||
|
**B. llama.cpp RPC mode under tracker orchestration.** llama.cpp ships a
|
||||||
|
native RPC backend that splits one model across machines. The tracker would
|
||||||
|
provision/route to an llama.cpp RPC cluster rather than our own hop pipeline.
|
||||||
|
Less code, but bypasses our billing/telemetry hop instrumentation and relay
|
||||||
|
NAT path — needs a story for both.
|
||||||
|
|
||||||
|
**C. Whole-model GGUF nodes (no sharding).** A node with enough memory serves
|
||||||
|
a full GGUF (e.g. IQ2/IQ3 on a 128 GB box); the tracker routes whole requests
|
||||||
|
to it (single-hop route). Smallest step, no cross-node activation work, and
|
||||||
|
already useful: Strix Halo 128 GB serves DeepSeek-V4-Flash IQ3_XXS (114 GB)
|
||||||
|
via llama.cpp Vulkan today.
|
||||||
|
|
||||||
|
Recommended sequencing: C first (small, real value), then A/B investigation.
|
||||||
|
|
||||||
|
## Also in scope
|
||||||
|
|
||||||
|
- Model catalog: allow GGUF entries with quant selection; feature
|
||||||
|
`DeepSeek-V4-Flash` IQ4_XS/UD-Q4_K_XL as a curated/featured entry once at
|
||||||
|
least direction C works (a featured model nobody can load is an anti-feature)
|
||||||
|
- Hardware detection: recognize Strix Halo/unified-memory APUs and Vulkan
|
||||||
|
(`hardware.py` currently reports "CPU mode" on these boxes)
|
||||||
|
- `MESHNET_DOWNLOAD_DIR`/`--download-dir` applies to GGUF files as well
|
||||||
|
|
||||||
|
## Acceptance criteria (phase C)
|
||||||
|
|
||||||
|
- A node with `--gguf <repo-or-path> --quant IQ3_XXS` serves
|
||||||
|
`/v1/chat/completions` via llama.cpp with GPU offload where available
|
||||||
|
- Tracker treats it as a full-coverage node (single-hop routes, billing works)
|
||||||
|
- Streamed responses work through the tracker proxy and the relay (US-036)
|
||||||
|
- `python -m pytest` passes from repo root (llama.cpp behind an optional extra)
|
||||||
38
docs/issues/43-dashboard-model-search-cards.md
Normal file
38
docs/issues/43-dashboard-model-search-cards.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# US-043 — Dashboard model search and model cards
|
||||||
|
|
||||||
|
Status: planned
|
||||||
|
Priority: Medium (post-deploy polish)
|
||||||
|
Stage: Idea
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The dashboard shows nodes/routes/billing but nothing model-centric. Operators
|
||||||
|
and testers should be able to search for a model and see, per model, a card
|
||||||
|
with what the network knows about it.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- **Search**: query box hitting a new tracker endpoint that proxies the HF Hub
|
||||||
|
search API (server-side, so the dashboard stays CSP-clean and unauthenticated
|
||||||
|
browsers aren't rate-limited) merged with the tracker's own model presets and
|
||||||
|
currently-served models.
|
||||||
|
- **Model card** per result:
|
||||||
|
- name, architecture, params, layer count (reuse `model_metadata_for`,
|
||||||
|
which now handles nested `text_config` — US layer-detection fix)
|
||||||
|
- coverage on the network: which layer ranges are served, by how many nodes,
|
||||||
|
coverage gaps (the Coverage Map already exists on the tracker)
|
||||||
|
- price per 1K tokens, availability (routable now? single-hop or pipeline?)
|
||||||
|
- memory footprint per quantization where known (bf16 / GGUF sizes)
|
||||||
|
- action: "request this model" — flags demand so node operators (or
|
||||||
|
auto-shard assignment) know what to load next
|
||||||
|
- Featured models section driven by the curated catalog (`CURATED_MODELS`),
|
||||||
|
including GGUF entries once US-042 lands.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Searching a HF repo id or free text returns results without the browser
|
||||||
|
calling HF directly
|
||||||
|
- A served model's card shows live coverage and a working "chat now" state
|
||||||
|
- An unserved model's card shows the "request" action and estimated memory
|
||||||
|
per quant
|
||||||
|
- `python -m pytest` passes from repo root
|
||||||
103
docs/issues/44-tracker-shard-source-partial-download.md
Normal file
103
docs/issues/44-tracker-shard-source-partial-download.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# US-044 — Tracker as model-file source; nodes download only their shard
|
||||||
|
|
||||||
|
Status: in progress
|
||||||
|
Priority: High (blocks multi-machine big-model serving; pairs with US-042)
|
||||||
|
Stage: Designed (grill remaining decisions before build)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Common deployment: the tracker and the first node share a machine that already
|
||||||
|
holds the model files (e.g. `/run/media/popov/DATA/llm/safetensor`). When a
|
||||||
|
second node joins with no model selected, the tracker assigns it the uncovered
|
||||||
|
layer range — and today that node then downloads the **entire snapshot from
|
||||||
|
HuggingFace**, even for a 20-layer shard of a 160 GB model.
|
||||||
|
|
||||||
|
What exists already (build on it, don't duplicate):
|
||||||
|
|
||||||
|
- Nodes serve their shard dir as a tar at `GET /v1/shards/download` with
|
||||||
|
checksum verification; `download_shard` tries assignment-provided `peers`
|
||||||
|
before HF (`downloader.py`). But it only matches **identical layer ranges**,
|
||||||
|
and the HF fallback runs `snapshot_download` of the whole repo.
|
||||||
|
- The torch path (`--model-id`) bypasses `download_shard` entirely:
|
||||||
|
`TorchModelShard` → `from_pretrained` downloads **and loads into RAM** the
|
||||||
|
full model, then executes only the assigned layers. Sharding currently saves
|
||||||
|
compute, not memory or bandwidth.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **Tracker `--models-dir PATH`** (env `MESHNET_MODELS_DIR`). When set, the
|
||||||
|
tracker indexes HF-layout snapshots under it and advertises itself as a
|
||||||
|
model-file source in `/v1/nodes/assign` responses.
|
||||||
|
2. **Layer-aware file selection.** For safetensors models, read
|
||||||
|
`model.safetensors.index.json` and map the assigned layer range → the
|
||||||
|
subset of weight files containing those layers, plus the always-needed
|
||||||
|
files (config, tokenizer, index, embeddings/head files for head/tail
|
||||||
|
shards). Serve exactly that subset (tar stream, per-file checksums).
|
||||||
|
GGUF (US-042): single file or naive byte-range — phase 2.
|
||||||
|
3. **Node download order**: exact-shard peer (existing) → tracker/peer file
|
||||||
|
subset (new) → HF `snapshot_download` with `allow_patterns` for the same
|
||||||
|
subset (new — stop downloading the whole repo even from HF) → full snapshot
|
||||||
|
(last resort).
|
||||||
|
- The `allow_patterns` subset must not depend on the tracker having a local
|
||||||
|
snapshot: when the tracker has no `--models-dir` match for a repo (or
|
||||||
|
hasn't cached it yet — the common case for a fresh public tracker),
|
||||||
|
`model_sources` comes back empty and `download_shard` falls straight to
|
||||||
|
`_download_huggingface_subset(..., allow_patterns=None)`, i.e. the full
|
||||||
|
repo. Reported 2026-07-06: a CPU node assigned layers 0–2 of
|
||||||
|
`unsloth/Qwen3.6-35B-A3B` (42 safetensor shards) sat downloading the
|
||||||
|
entire model unauthenticated because of this. Fix: fetch
|
||||||
|
`model.safetensors.index.json` + `config.json` directly from HF (a few
|
||||||
|
KB) and compute the same layer-scoped file subset client-side, so the
|
||||||
|
HF-fallback path is filtered even with an empty `model_sources`.
|
||||||
|
4. **Partial LOAD (the hard half).** Downloading a subset is wasted unless the
|
||||||
|
node stops instantiating the full model: build the model skeleton on the
|
||||||
|
`meta` device, materialize only assigned layers (+embeddings/norm/head as
|
||||||
|
role requires) from the local files, leave the rest on meta. Without this,
|
||||||
|
an 80 GB machine can never hold a shard of a 160 GB model regardless of
|
||||||
|
how the bytes arrive. This is the acceptance bar for the issue.
|
||||||
|
|
||||||
|
## Open questions (grill before building)
|
||||||
|
|
||||||
|
- Trust: joining nodes fetch weights from the tracker/peers — checksum against
|
||||||
|
what root of trust? (HF etag/sha vs tracker-signed manifest.)
|
||||||
|
- Disk layout: partial snapshots must not corrupt the HF cache dir; probably
|
||||||
|
a meshnet-owned layout keyed by repo+revision.
|
||||||
|
- Serving cost: a 100 GB tar stream per joining node on the tracker box —
|
||||||
|
rate-limit/queue? LAN-only heuristic?
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Tracker can be started with `--models-dir PATH` / `MESHNET_MODELS_DIR`
|
||||||
|
and advertises a local model-file source in assignment responses when it has
|
||||||
|
a matching HF snapshot.
|
||||||
|
- [x] Tracker serves a tar stream containing only the safetensors files selected
|
||||||
|
for the assigned layer range plus config/tokenizer/index metadata.
|
||||||
|
- [x] Node downloader keeps exact-shard peers first, then races tracker model
|
||||||
|
sources against a HuggingFace `snapshot_download(..., allow_patterns=...)`
|
||||||
|
subset download, using the first successful source.
|
||||||
|
- [x] When no tracker model source is available at all, the HuggingFace
|
||||||
|
fallback still computes `allow_patterns` from the repo's own
|
||||||
|
`model.safetensors.index.json` (fetched directly, not via the tracker) —
|
||||||
|
it never silently downloads the full model just because the tracker has
|
||||||
|
nothing cached.
|
||||||
|
- [x] Real PyTorch model startup can use tracker `full_url` sources to fetch
|
||||||
|
the full local snapshot over LAN before `from_pretrained`, so local-network
|
||||||
|
testing no longer has to pull from HuggingFace first.
|
||||||
|
- [ ] Two-machine test: machine A (tracker + node, holds full snapshot) serves
|
||||||
|
layers 0–k; machine B joins with no model and receives **only** the files
|
||||||
|
for its assigned range from A — nothing fetched from HF
|
||||||
|
- [ ] Machine B's resident memory scales with its shard size, not model size
|
||||||
|
- [ ] Checksums verified end-to-end; corrupted transfer falls back cleanly
|
||||||
|
- [x] Single-node/full-model flows unchanged
|
||||||
|
- [ ] `python -m pytest` passes from repo root
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
- 2026-07-06: Added the tracker/node download path. For immediate Qwen3.6-35B
|
||||||
|
LAN testing, real PyTorch nodes fetch the full snapshot from the tracker via
|
||||||
|
`full_url`; HuggingFace remains fallback-only, and when it is used the node
|
||||||
|
computes `allow_patterns` from the repo's remote SafeTensors index so it
|
||||||
|
stays layer-filtered even without tracker-cached files. Remaining hard half
|
||||||
|
is true partial model materialization: the backend can prefer a downloaded
|
||||||
|
local model directory, but Transformers still needs a `meta`-device load
|
||||||
|
path that materializes only assigned layers.
|
||||||
60
docs/issues/45-dual-rate-billing.md
Normal file
60
docs/issues/45-dual-rate-billing.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# US-045 — Dual-rate billing: separate input and output token prices
|
||||||
|
|
||||||
|
Status: in progress
|
||||||
|
Priority: High (billing correctness before friends test; providers all price this way)
|
||||||
|
Stage: Designed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Today the ledger has one `price_per_1k_tokens` per model, and the two proxy
|
||||||
|
paths don't even agree on what they count:
|
||||||
|
|
||||||
|
- **Non-streaming** bills `usage.total_tokens` (prompt + completion) at the
|
||||||
|
blended rate (`_billable_non_stream_tokens`).
|
||||||
|
- **Streaming** bills `min(observed output deltas, reported total)` — output
|
||||||
|
only in practice (`_billable_stream_tokens`).
|
||||||
|
- The HF pricing refresher (issue 23) averages a provider's input/output
|
||||||
|
rates 50/50 (`blended_price_per_1k_tokens`), which misprices asymmetric
|
||||||
|
models — e.g. Qwen3.6-35B-A3B on deepinfra is $0.15/1M in, $0.95/1M out.
|
||||||
|
|
||||||
|
Decision (user, 2026-07-06): charge **both** input and output tokens, at
|
||||||
|
**two separate rates**, same as other providers.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **`BillingLedger`** stores `{model: (input_per_1k, output_per_1k)}`.
|
||||||
|
- `set_prices(model, input_per_1k, output_per_1k)` (new);
|
||||||
|
`set_price(model, p)` keeps working and sets both.
|
||||||
|
- `prices_for(model) -> (input, output)` (new); `price_for(model)` returns
|
||||||
|
the blended average for back-compat (estimators/logs).
|
||||||
|
- `charge_request(...)` gains keyword `input_tokens`/`output_tokens`; when
|
||||||
|
provided, `cost = in_rate·in/1k + out_rate·out/1k` and the event records
|
||||||
|
the split. Without them, legacy behavior (blended × total) — old events
|
||||||
|
and gossip replicas replay unchanged (`cost` stays the applied field).
|
||||||
|
2. **Token counting** (`server.py`):
|
||||||
|
- Non-stream: prefer `usage.prompt_tokens`/`completion_tokens`; fall back
|
||||||
|
to content estimates (`_estimate_prompt_tokens`, observed completion),
|
||||||
|
capped by `max_tokens` bounds as today.
|
||||||
|
- Stream (direct + relay): output = observed deltas as today; input =
|
||||||
|
`usage.prompt_tokens` when a usage chunk appears, else the prompt
|
||||||
|
estimate from the request body. `_stream_line_tokens` returns the parsed
|
||||||
|
usage triple instead of just the total.
|
||||||
|
3. **Presets**: `input_price_per_1k_tokens` / `output_price_per_1k_tokens`
|
||||||
|
(dual keys win; `price_per_1k_tokens` alone still means "both rates").
|
||||||
|
Qwen3.6-35B-A3B: input 0.00012, output 0.00076 (80% of deepinfra).
|
||||||
|
4. **HF refresher**: applies 80% of each side separately via `set_prices`
|
||||||
|
(all alias keys); change log keeps recording the blended pair for history
|
||||||
|
continuity.
|
||||||
|
5. **Spend cap** (`--max-charge-per-request`): estimate =
|
||||||
|
`in_rate·prompt_estimate + out_rate·completion_limit`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Streamed and non-streamed requests for the same exchange bill the same
|
||||||
|
split (input charged in both)
|
||||||
|
- A model with asymmetric provider rates bills input and output differently;
|
||||||
|
`usage_for` / billing events expose the split
|
||||||
|
- Old persisted billing events replay byte-identically (balances unchanged)
|
||||||
|
- HF refresh sets both rates from the marketplace row, not the average
|
||||||
|
- Spend cap uses the dual rates
|
||||||
|
- `python -m pytest` passes from repo root
|
||||||
63
docs/issues/46-tracker-env-and-first-node-autojoin.md
Normal file
63
docs/issues/46-tracker-env-and-first-node-autojoin.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# US-046 — Tracker .env awareness + first-node auto-join bootstrap
|
||||||
|
|
||||||
|
Status: in progress
|
||||||
|
Priority: High (blocks the US-044 two-machine test; auto-join dead on fresh trackers)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Reported 2026-07-06: a node auto-joining a fresh tracker prints
|
||||||
|
`(auto-join unavailable: HTTP Error 503)` and then cannot download the model
|
||||||
|
from the tracker. Two independent causes:
|
||||||
|
|
||||||
|
1. **Bootstrap chicken-and-egg.** `_handle_network_assign` handles an empty
|
||||||
|
registry by falling back to the first *deployable* recommended preset — but
|
||||||
|
deployability is computed from the registered pool only
|
||||||
|
(`_deployment_summary(all_nodes, preset)`), which is empty, so nothing is
|
||||||
|
ever deployable and the first node always gets 503. The caller's own
|
||||||
|
`vram_mb` / `ram_mb` (already sent in the query) are ignored.
|
||||||
|
2. **Tracker ignores `.env`.** `meshnet-node` loads `.env` (cwd) and
|
||||||
|
`~/.config/meshnet/secrets.env` at startup; `meshnet-tracker` does not. The
|
||||||
|
repo `.env` sets `MESHNET_DOWNLOAD_DIR=/run/media/popov/DATA/llm/safetensor/models`,
|
||||||
|
but the tracker only reads `--models-dir` / `MESHNET_MODELS_DIR`, so
|
||||||
|
`models_dir` stays unset → `/v1/model-files/download` returns
|
||||||
|
404 "tracker model-file source is not enabled" and assignments carry no
|
||||||
|
`model_sources`.
|
||||||
|
3. **Symlink tars (found while verifying).** HF cache snapshots are symlink
|
||||||
|
farms into `blobs/`; both the tracker's `/v1/model-files/download` and the
|
||||||
|
node's `write_shard_archive` tarred the symlinks themselves
|
||||||
|
(`tarfile` default `dereference=False`), so receivers extracted dangling
|
||||||
|
links instead of weights.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
1. In the empty-registry branch of `_handle_network_assign`, synthesize a
|
||||||
|
candidate `_NodeEntry` from the caller's `vram_mb`/`ram_mb` query params and
|
||||||
|
include it in the pool used for the deployability gate (and the reported
|
||||||
|
`deployment` summary), so a recommended preset that fits *pool + caller*
|
||||||
|
bootstraps the network.
|
||||||
|
2. Tracker CLI loads env defaults the same way the node CLI does
|
||||||
|
(`.env` in cwd, then `~/.config/meshnet/secrets.env`, never overriding
|
||||||
|
already-set env vars).
|
||||||
|
3. `TrackerServer` models-dir resolution falls back
|
||||||
|
`--models-dir` → `MESHNET_MODELS_DIR` → `MESHNET_DOWNLOAD_DIR` (the node
|
||||||
|
store and tracker source are the same directory on a box running both).
|
||||||
|
4. Archive with `dereference=True` in both tar writers so model file contents
|
||||||
|
ship instead of snapshot symlinks.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Fresh tracker (empty registry) + caller with enough memory for a
|
||||||
|
recommended preset → `/v1/network/assign` returns 200 with that preset,
|
||||||
|
`gap_found=true`, and `model_sources` populated when the tracker holds a
|
||||||
|
local snapshot. (Verified live: 128 GB caller got qwen3.6-35b-a3b 0–39
|
||||||
|
with tracker `model_sources`.)
|
||||||
|
- [x] Fresh tracker + caller too small for any recommended preset → still 503.
|
||||||
|
(Verified live with a 4 GB caller.)
|
||||||
|
- [x] `meshnet-tracker start` in a directory with `.env` setting
|
||||||
|
`MESHNET_DOWNLOAD_DIR` serves `/v1/model-files/download` from that dir with
|
||||||
|
no extra flags. (Verified live; tar entries are regular files, not
|
||||||
|
symlinks.)
|
||||||
|
- [x] Explicit `--models-dir` and `MESHNET_MODELS_DIR` still take precedence,
|
||||||
|
in that order.
|
||||||
|
- [x] `python -m pytest` passes from repo root (two known env-dependent
|
||||||
|
failures occur only while a live meshnet-node holds port 7000).
|
||||||
115
docs/issues/47-model-source-download-visibility.md
Normal file
115
docs/issues/47-model-source-download-visibility.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# US-047 — Tracker-first model downloads: visibility, sane timeouts, RAM-based sizing
|
||||||
|
|
||||||
|
Status: in progress
|
||||||
|
Priority: High (follow-up to US-044/US-046; blocks usable LAN downloads)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Reported 2026-07-06 (Windows CPU node, 79.2 GB RAM, `--tracker
|
||||||
|
http://192.168.0.179:8080 --model Qwen3.6-35B-A3B`):
|
||||||
|
|
||||||
|
1. Startup prints `(auto-join unavailable: HTTP Error 503)` even though the
|
||||||
|
user explicitly named a model. The auto-join query (`/v1/network/assign`)
|
||||||
|
never sends the requested model, so a fresh tracker + a caller too small
|
||||||
|
for the *recommended* preset 503s (expected per US-046) — but the whole
|
||||||
|
auto-join step is pointless when the user already picked a model: the
|
||||||
|
`/v1/nodes/assign?model=…` call right after it succeeds (assigned layers
|
||||||
|
0–2 with tracker `model_sources`).
|
||||||
|
2. The tracker-vs-HuggingFace race then starts, but only HuggingFace shows
|
||||||
|
progress (hf tqdm bars). The tracker tar download prints nothing and
|
||||||
|
swallows every failure (`except Exception: return None`), so the node
|
||||||
|
*appears* to download only from slow HF; the user killed it. Tracker-side
|
||||||
|
log showed the tar stream reset mid-`archive.add` — with no way to tell
|
||||||
|
whether the client timed out or the user aborted.
|
||||||
|
3. `_download_model_source` inherits `peer_timeout` (2.0 s) as its urlopen
|
||||||
|
socket timeout. Any 2 s read stall during a multi-GB tar stream silently
|
||||||
|
kills the tracker source and leaves HF as the only contender.
|
||||||
|
4. Every client abort spams the tracker console with a full
|
||||||
|
`BrokenPipeError`/`ConnectionResetError` traceback from `socketserver`.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
1. `startup.py`: skip the network auto-join query entirely when a model was
|
||||||
|
explicitly requested (`model` set and not `"stub-model"`); path 3b
|
||||||
|
(`/v1/nodes/assign?model=…`) is the authoritative one there.
|
||||||
|
2. `downloader.py`: model-source downloads get their own timeout constant
|
||||||
|
(30 s socket timeout) instead of the 2 s peer-probe timeout. Peer shard
|
||||||
|
downloads keep 2 s — they run sequentially before the race, and a dead
|
||||||
|
peer must not hang startup for 30 s; the race is concurrent so a slow
|
||||||
|
source costs nothing.
|
||||||
|
3. `downloader.py`: progress + failure visibility for the race —
|
||||||
|
`_download_model_source` prints received bytes every 512 MB and prints
|
||||||
|
the exception when a source fails, so "downloads only from HF" can never
|
||||||
|
happen silently again.
|
||||||
|
4. Tracker `_handle_model_files_download`: catch
|
||||||
|
`BrokenPipeError`/`ConnectionResetError` around the tar stream and log a
|
||||||
|
single line instead of a traceback.
|
||||||
|
|
||||||
|
## Design revision (2026-07-06, after live retest)
|
||||||
|
|
||||||
|
The race is gone. User decision: **HuggingFace is used only when the model is
|
||||||
|
not available from a tracker/peer source, or when `--tracker-source-disabled`
|
||||||
|
is passed.** Sources are tried sequentially with progress + failure output;
|
||||||
|
HF (layer-filtered via the source file list, else the remote index) is the
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
Second live finding: the node was assigned only layers 0–2 of 40 on a 79 GB
|
||||||
|
box. Cause: CPU-mode nodes still report the detected-but-unusable GPU's
|
||||||
|
`vram_mb` (RTX 4060 → 8192), and shard sizing used VRAM whenever it was > 0
|
||||||
|
(8 GB × 0.8 ≈ 6.5 GB ≈ 3 layers). Fixed on both sides: the node now sends
|
||||||
|
`assignment_vram_mb` (0 unless CUDA is actually usable) to `/v1/nodes/assign`,
|
||||||
|
and the tracker only trusts `vram_mb` when `device=cuda` (all three sizing
|
||||||
|
sites), falling back to `ram_mb`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Node started with an explicit `--model` never queries
|
||||||
|
`/v1/network/assign` and never prints `auto-join unavailable`.
|
||||||
|
- [x] Tracker/peer model source is preferred outright; HF is contacted only
|
||||||
|
when no source is advertised, every source fails, or
|
||||||
|
`--tracker-source-disabled` is passed (flag on both CLI parsers, plumbed
|
||||||
|
through config and `run_startup`).
|
||||||
|
- [x] Tracker-source downloads print progress every 512 MB and print the
|
||||||
|
exception + URL on failure; nothing fails silently.
|
||||||
|
- [x] A ≥2 s read stall no longer aborts a tracker model-source download
|
||||||
|
(30 s socket timeout).
|
||||||
|
- [x] Client disconnect during `/v1/model-files/download` logs one line on
|
||||||
|
the tracker, no traceback.
|
||||||
|
- [x] CPU node with big RAM gets a RAM-sized shard: `/v1/nodes/assign` and
|
||||||
|
both `/v1/network/assign` sizing paths ignore VRAM unless `device=cuda`.
|
||||||
|
- [x] `pytest tests/test_node_startup.py tests/test_tracker_routing.py`
|
||||||
|
passes (139/140; the one failure is the pre-existing port-dependent
|
||||||
|
`test_mining_cli` case, present on clean master).
|
||||||
|
- [ ] Live two-machine retest: Windows node downloads only from tracker at
|
||||||
|
LAN speed and is assigned a RAM-sized shard.
|
||||||
|
|
||||||
|
## Round 3 (2026-07-06, after live retest showed mid-stream RST)
|
||||||
|
|
||||||
|
Live retest: RAM sizing worked (layers 0–36) and the failure finally printed —
|
||||||
|
`ConnectionResetError(10054)` ~70 s into the tar stream. Local reproduction
|
||||||
|
cleared the tracker: it streams the full 72 GB tar at ~900 MB/s, survives a
|
||||||
|
3-minute slow reader, and logs aborts in one line. The RST comes from the
|
||||||
|
network path (Windows laptop, likely WiFi + firewall/AV) — and a 72 GB
|
||||||
|
single-TCP-stream tar is inherently fragile there.
|
||||||
|
|
||||||
|
Fix: per-file downloads (design principle: nodes must be able to fetch any
|
||||||
|
missing shard or the complete model from the tracker alone — no hard HF
|
||||||
|
dependency):
|
||||||
|
|
||||||
|
- Tracker: `/v1/model-files/download?...&file=<rel>` streams one file with
|
||||||
|
`Content-Length` (rel must be in the requested shard/full set; traversal
|
||||||
|
rejected). `model_sources` now advertises `full_files` and a `file_sizes`
|
||||||
|
manifest.
|
||||||
|
- Node: `_download_source_files` fetches per file into
|
||||||
|
`<shard>.partial/`, retries each file 3×, verifies against
|
||||||
|
`Content-Length`, and reuses already-complete files (hardlink from the
|
||||||
|
existing shard) via the size manifest — so restarts and drops cost at most
|
||||||
|
one file. Tar stream remains the fallback for old trackers
|
||||||
|
(detected via Content-Type) and sources without a file list.
|
||||||
|
- `_full_model_sources` passes `full_files` through, so full-snapshot
|
||||||
|
downloads for the torch path get the same robustness.
|
||||||
|
|
||||||
|
Verified live against a local tracker: 14.7 GB shard in 7.6 s per-file;
|
||||||
|
re-run over a complete shard instant; corrupt + deleted file recovered in
|
||||||
|
1.5 s re-fetching only those two. 114 tests pass (node_startup +
|
||||||
|
tracker_routing).
|
||||||
@@ -155,7 +155,8 @@
|
|||||||
"US-003"
|
"US-003"
|
||||||
],
|
],
|
||||||
"completionNotes": "Completed by fresh Ralph/Codex session c257ffde with controller patches for clarified economics; verified by pytest, compileall, and diff check.",
|
"completionNotes": "Completed by fresh Ralph/Codex session c257ffde with controller patches for clarified economics; verified by pytest, compileall, and diff check.",
|
||||||
"status": "done"
|
"status": "done",
|
||||||
|
"status_reason": "Superseded for alpha by ADR-0015 (US-030..US-035): custodial USDT treasury + off-chain ledger replaces the Anchor stake/registry/settlement contracts, and targets Solana devnet (mock-USDT mint) rather than the testnet-only rule in this story's description. See docs/issues/06-solana-stake-and-settlement.md and ADR-0016."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-007",
|
"id": "US-007",
|
||||||
|
|||||||
BIN
meshnet_registry.sqlite3
Normal file
BIN
meshnet_registry.sqlite3
Normal file
Binary file not shown.
@@ -7,10 +7,26 @@ replace this adapter later without changing gateway or tracker call sites.
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import json
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
# ADR-0018 §6 graduated reputation: slow build, instant loss, inactivity
|
||||||
|
# decay. Tunable; not derived from any peer/self-report signal.
|
||||||
|
REPUTATION_MIN = 0.0
|
||||||
|
REPUTATION_MAX = 1.0
|
||||||
|
REPUTATION_CLEAN_AUDIT_DELTA = 0.05
|
||||||
|
REPUTATION_FAILED_AUDIT_DELTA = -0.3
|
||||||
|
REPUTATION_INACTIVITY_DECAY = 0.05
|
||||||
|
REPUTATION_INACTIVITY_SECONDS = 30 * 86400.0
|
||||||
|
# Per-strike routing/payout weight decay — separate from forfeiture, which
|
||||||
|
# stays full pending balance (ADR-0018 §1, §6).
|
||||||
|
ROUTING_STRIKE_MULTIPLIER = 0.8
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RegistryWallet:
|
class RegistryWallet:
|
||||||
@@ -20,6 +36,169 @@ class RegistryWallet:
|
|||||||
strike_count: int = 0
|
strike_count: int = 0
|
||||||
banned: bool = False
|
banned: bool = False
|
||||||
completed_job_count: int = 0
|
completed_job_count: int = 0
|
||||||
|
reputation: float = 1.0
|
||||||
|
last_audit_ts: float | None = None
|
||||||
|
last_active_ts: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def routing_multiplier(self) -> float:
|
||||||
|
"""ADR-0018 §6: ×0.8 routing/payout weight decay per strike."""
|
||||||
|
return ROUTING_STRIKE_MULTIPLIER ** self.strike_count
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryEventLog:
|
||||||
|
"""Thread-safe registry wallet event log with SQLite persistence."""
|
||||||
|
|
||||||
|
def __init__(self, state: "_LocalContractState", db_path: str | None = None) -> None:
|
||||||
|
self._state = state
|
||||||
|
self._db_path = db_path
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._seen_event_ids: set[str] = set()
|
||||||
|
self._event_log: list[dict] = []
|
||||||
|
self._dirty = False
|
||||||
|
if self._db_path:
|
||||||
|
self._init_db()
|
||||||
|
self._load_from_db()
|
||||||
|
|
||||||
|
def events_since(self, index: int) -> tuple[list[dict], int]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._event_log[index:]), len(self._event_log)
|
||||||
|
|
||||||
|
def apply_events(self, events: list[dict]) -> int:
|
||||||
|
applied = 0
|
||||||
|
with self._lock:
|
||||||
|
for event in events:
|
||||||
|
event_id = event.get("id")
|
||||||
|
if not event_id or event_id in self._seen_event_ids:
|
||||||
|
continue
|
||||||
|
self._apply_locked(event)
|
||||||
|
applied += 1
|
||||||
|
return applied
|
||||||
|
|
||||||
|
def record(self, event: dict) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._apply_locked(event)
|
||||||
|
|
||||||
|
def _apply_locked(self, event: dict) -> None:
|
||||||
|
etype = event.get("type")
|
||||||
|
wallet_address = event.get("wallet")
|
||||||
|
if not isinstance(wallet_address, str) or not wallet_address:
|
||||||
|
return
|
||||||
|
current = self._state.registry.get(wallet_address, RegistryWallet())
|
||||||
|
updated: RegistryWallet | None = None
|
||||||
|
if etype == "stake":
|
||||||
|
updated = _registry_wallet_with(current, stake_balance=current.stake_balance + int(event["amount"]))
|
||||||
|
elif etype == "strike":
|
||||||
|
strike_count = current.strike_count + int(event.get("count", 1))
|
||||||
|
updated = _registry_wallet_with(
|
||||||
|
current,
|
||||||
|
strike_count=strike_count,
|
||||||
|
banned=current.banned or strike_count >= int(event.get("ban_threshold", 3)),
|
||||||
|
)
|
||||||
|
elif etype == "slash":
|
||||||
|
strike_count = current.strike_count + 1
|
||||||
|
updated = _registry_wallet_with(
|
||||||
|
current,
|
||||||
|
stake_balance=max(0, current.stake_balance - int(event["slash_amount"])),
|
||||||
|
strike_count=strike_count,
|
||||||
|
banned=current.banned or strike_count >= int(event.get("strike_threshold", 3)),
|
||||||
|
)
|
||||||
|
elif etype == "ban":
|
||||||
|
updated = _registry_wallet_with(current, banned=True)
|
||||||
|
elif etype == "completed_job":
|
||||||
|
updated = _registry_wallet_with(
|
||||||
|
current,
|
||||||
|
completed_job_count=current.completed_job_count + int(event.get("count", 1)),
|
||||||
|
last_active_ts=float(event.get("ts", time.time())),
|
||||||
|
)
|
||||||
|
elif etype == "set_reputation":
|
||||||
|
updated = _registry_wallet_with(current, reputation=float(event["reputation"]))
|
||||||
|
elif etype == "audit":
|
||||||
|
updated = _registry_wallet_with(current, last_audit_ts=float(event["ts"]))
|
||||||
|
elif etype == "audit_outcome":
|
||||||
|
# ADR-0018 §6: reputation moves only from tracker-verified audit
|
||||||
|
# outcomes. Strike/ban stay on the existing slash/strike events —
|
||||||
|
# this event never touches strike_count to avoid double-counting
|
||||||
|
# a single failed audit that already went through submit_slash_proof.
|
||||||
|
delta = REPUTATION_CLEAN_AUDIT_DELTA if event["passed"] else REPUTATION_FAILED_AUDIT_DELTA
|
||||||
|
reputation = min(REPUTATION_MAX, max(REPUTATION_MIN, current.reputation + delta))
|
||||||
|
updated = _registry_wallet_with(
|
||||||
|
current,
|
||||||
|
reputation=reputation,
|
||||||
|
last_audit_ts=float(event.get("ts", time.time())),
|
||||||
|
)
|
||||||
|
elif etype == "inactivity_decay":
|
||||||
|
reputation = max(
|
||||||
|
REPUTATION_MIN,
|
||||||
|
current.reputation - float(event.get("amount", REPUTATION_INACTIVITY_DECAY)),
|
||||||
|
)
|
||||||
|
updated = _registry_wallet_with(
|
||||||
|
current,
|
||||||
|
reputation=reputation,
|
||||||
|
# Resets the idle clock so decay applies at most once per window.
|
||||||
|
last_active_ts=float(event.get("ts", time.time())),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
self._state.registry[wallet_address] = updated
|
||||||
|
self._seen_event_ids.add(event["id"])
|
||||||
|
self._event_log.append(event)
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
|
def _init_db(self) -> None:
|
||||||
|
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
|
||||||
|
con.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS registry_events "
|
||||||
|
"(event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, ts REAL NOT NULL)"
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
def _load_from_db(self) -> None:
|
||||||
|
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT payload FROM registry_events ORDER BY ts, event_id"
|
||||||
|
).fetchall()
|
||||||
|
con.close()
|
||||||
|
with self._lock:
|
||||||
|
for (payload,) in rows:
|
||||||
|
try:
|
||||||
|
event = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if event.get("id") not in self._seen_event_ids:
|
||||||
|
self._apply_locked(event)
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
|
def save_to_db(self) -> None:
|
||||||
|
if not self._db_path:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
if not self._dirty:
|
||||||
|
return
|
||||||
|
events = list(self._event_log)
|
||||||
|
self._dirty = False
|
||||||
|
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
|
||||||
|
con.executemany(
|
||||||
|
"INSERT OR IGNORE INTO registry_events (event_id, payload, ts) VALUES (?, ?, ?)",
|
||||||
|
[(e["id"], json.dumps(e), float(e.get("ts", 0.0))) for e in events],
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_wallet_with(wallet: RegistryWallet, **changes: object) -> RegistryWallet:
|
||||||
|
values = {
|
||||||
|
"stake_balance": wallet.stake_balance,
|
||||||
|
"strike_count": wallet.strike_count,
|
||||||
|
"banned": wallet.banned,
|
||||||
|
"completed_job_count": wallet.completed_job_count,
|
||||||
|
"reputation": wallet.reputation,
|
||||||
|
"last_audit_ts": wallet.last_audit_ts,
|
||||||
|
"last_active_ts": wallet.last_active_ts,
|
||||||
|
}
|
||||||
|
values.update(changes)
|
||||||
|
return RegistryWallet(**values)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -62,6 +241,7 @@ class ValidationEvent:
|
|||||||
messages: list[dict]
|
messages: list[dict]
|
||||||
observed_output: str
|
observed_output: str
|
||||||
route_nodes: list[dict]
|
route_nodes: list[dict]
|
||||||
|
ts: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -103,34 +283,34 @@ class _LocalContractState:
|
|||||||
class RegistryContract:
|
class RegistryContract:
|
||||||
"""Registry wrapper for node stake, strikes, and bans."""
|
"""Registry wrapper for node stake, strikes, and bans."""
|
||||||
|
|
||||||
def __init__(self, state: _LocalContractState, cluster: str) -> None:
|
def __init__(self, state: _LocalContractState, cluster: str, event_log: RegistryEventLog) -> None:
|
||||||
self._state = state
|
self._state = state
|
||||||
self._cluster = cluster
|
self._cluster = cluster
|
||||||
|
self._event_log = event_log
|
||||||
|
|
||||||
def submit_stake(self, wallet_address: str, amount: int) -> dict:
|
def submit_stake(self, wallet_address: str, amount: int) -> dict:
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
raise ValueError("stake amount must be positive")
|
raise ValueError("stake amount must be positive")
|
||||||
current = self.get_wallet(wallet_address)
|
self._event_log.record({
|
||||||
self._state.registry[wallet_address] = RegistryWallet(
|
"id": f"stake-{uuid.uuid4().hex}",
|
||||||
stake_balance=current.stake_balance + amount,
|
"type": "stake",
|
||||||
strike_count=current.strike_count,
|
"wallet": wallet_address,
|
||||||
banned=current.banned,
|
"amount": amount,
|
||||||
completed_job_count=current.completed_job_count,
|
"ts": time.time(),
|
||||||
)
|
})
|
||||||
return {"signature": f"local-stake-{wallet_address}", "cluster": self._cluster}
|
return {"signature": f"local-stake-{wallet_address}", "cluster": self._cluster}
|
||||||
|
|
||||||
def get_wallet(self, wallet_address: str) -> RegistryWallet:
|
def get_wallet(self, wallet_address: str) -> RegistryWallet:
|
||||||
return self._state.registry.get(wallet_address, RegistryWallet())
|
return self._state.registry.get(wallet_address, RegistryWallet())
|
||||||
|
|
||||||
def record_strike(self, wallet_address: str, ban_threshold: int = 3) -> dict:
|
def record_strike(self, wallet_address: str, ban_threshold: int = 3) -> dict:
|
||||||
current = self.get_wallet(wallet_address)
|
self._event_log.record({
|
||||||
strike_count = current.strike_count + 1
|
"id": f"strike-{uuid.uuid4().hex}",
|
||||||
self._state.registry[wallet_address] = RegistryWallet(
|
"type": "strike",
|
||||||
stake_balance=current.stake_balance,
|
"wallet": wallet_address,
|
||||||
strike_count=strike_count,
|
"ban_threshold": ban_threshold,
|
||||||
banned=current.banned or strike_count >= ban_threshold,
|
"ts": time.time(),
|
||||||
completed_job_count=current.completed_job_count,
|
})
|
||||||
)
|
|
||||||
return {"signature": f"local-strike-{wallet_address}", "cluster": self._cluster}
|
return {"signature": f"local-strike-{wallet_address}", "cluster": self._cluster}
|
||||||
|
|
||||||
def submit_slash_proof(
|
def submit_slash_proof(
|
||||||
@@ -150,24 +330,24 @@ class RegistryContract:
|
|||||||
raise ValueError("strike_threshold must be positive")
|
raise ValueError("strike_threshold must be positive")
|
||||||
|
|
||||||
current = self.get_wallet(wallet_address)
|
current = self.get_wallet(wallet_address)
|
||||||
stake_after = max(0, current.stake_balance - slash_amount)
|
self._event_log.record({
|
||||||
strike_count = current.strike_count + 1
|
"id": f"slash-{uuid.uuid4().hex}",
|
||||||
banned = current.banned or strike_count >= strike_threshold
|
"type": "slash",
|
||||||
self._state.registry[wallet_address] = RegistryWallet(
|
"wallet": wallet_address,
|
||||||
stake_balance=stake_after,
|
"slash_amount": slash_amount,
|
||||||
strike_count=strike_count,
|
"strike_threshold": strike_threshold,
|
||||||
banned=banned,
|
"ts": time.time(),
|
||||||
completed_job_count=current.completed_job_count,
|
})
|
||||||
)
|
updated = self.get_wallet(wallet_address)
|
||||||
receipt = SlashReceipt(
|
receipt = SlashReceipt(
|
||||||
signature=f"local-slash-{wallet_address}-{len(self._state.slash_receipts) + 1}",
|
signature=f"local-slash-{wallet_address}-{len(self._state.slash_receipts) + 1}",
|
||||||
cluster=self._cluster,
|
cluster=self._cluster,
|
||||||
wallet_address=wallet_address,
|
wallet_address=wallet_address,
|
||||||
slash_amount=slash_amount,
|
slash_amount=slash_amount,
|
||||||
stake_before=current.stake_balance,
|
stake_before=current.stake_balance,
|
||||||
stake_after=stake_after,
|
stake_after=updated.stake_balance,
|
||||||
strike_count=strike_count,
|
strike_count=updated.strike_count,
|
||||||
banned=banned,
|
banned=updated.banned,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
)
|
)
|
||||||
self._state.slash_receipts.append(receipt)
|
self._state.slash_receipts.append(receipt)
|
||||||
@@ -175,13 +355,12 @@ class RegistryContract:
|
|||||||
return receipt
|
return receipt
|
||||||
|
|
||||||
def ban_wallet(self, wallet_address: str) -> dict:
|
def ban_wallet(self, wallet_address: str) -> dict:
|
||||||
current = self.get_wallet(wallet_address)
|
self._event_log.record({
|
||||||
self._state.registry[wallet_address] = RegistryWallet(
|
"id": f"ban-{uuid.uuid4().hex}",
|
||||||
stake_balance=current.stake_balance,
|
"type": "ban",
|
||||||
strike_count=current.strike_count,
|
"wallet": wallet_address,
|
||||||
banned=True,
|
"ts": time.time(),
|
||||||
completed_job_count=current.completed_job_count,
|
})
|
||||||
)
|
|
||||||
return {"signature": f"local-ban-{wallet_address}", "cluster": self._cluster}
|
return {"signature": f"local-ban-{wallet_address}", "cluster": self._cluster}
|
||||||
|
|
||||||
def has_minimum_stake(self, wallet_address: str | None, minimum_stake: int) -> bool:
|
def has_minimum_stake(self, wallet_address: str | None, minimum_stake: int) -> bool:
|
||||||
@@ -195,20 +374,91 @@ class RegistryContract:
|
|||||||
return dict(self._state.registry)
|
return dict(self._state.registry)
|
||||||
|
|
||||||
def record_completed_job(self, wallet_address: str) -> RegistryWallet:
|
def record_completed_job(self, wallet_address: str) -> RegistryWallet:
|
||||||
current = self.get_wallet(wallet_address)
|
self.record_completed_jobs(wallet_address, 1)
|
||||||
updated = RegistryWallet(
|
return self.get_wallet(wallet_address)
|
||||||
stake_balance=current.stake_balance,
|
|
||||||
strike_count=current.strike_count,
|
def record_completed_jobs(self, wallet_address: str, count: int, *, ts: float | None = None) -> RegistryWallet:
|
||||||
banned=current.banned,
|
if count <= 0:
|
||||||
completed_job_count=current.completed_job_count + 1,
|
raise ValueError("completed job count must be positive")
|
||||||
)
|
self._event_log.record({
|
||||||
self._state.registry[wallet_address] = updated
|
"id": f"job-{uuid.uuid4().hex}",
|
||||||
return updated
|
"type": "completed_job",
|
||||||
|
"wallet": wallet_address,
|
||||||
|
"count": count,
|
||||||
|
"ts": time.time() if ts is None else ts,
|
||||||
|
})
|
||||||
|
return self.get_wallet(wallet_address)
|
||||||
|
|
||||||
|
def set_reputation(self, wallet_address: str, reputation: float) -> RegistryWallet:
|
||||||
|
self._event_log.record({
|
||||||
|
"id": f"reputation-{uuid.uuid4().hex}",
|
||||||
|
"type": "set_reputation",
|
||||||
|
"wallet": wallet_address,
|
||||||
|
"reputation": reputation,
|
||||||
|
"ts": time.time(),
|
||||||
|
})
|
||||||
|
return self.get_wallet(wallet_address)
|
||||||
|
|
||||||
|
def record_audit(self, wallet_address: str, ts: float | None = None) -> RegistryWallet:
|
||||||
|
audit_ts = time.time() if ts is None else ts
|
||||||
|
self._event_log.record({
|
||||||
|
"id": f"audit-{uuid.uuid4().hex}",
|
||||||
|
"type": "audit",
|
||||||
|
"wallet": wallet_address,
|
||||||
|
"ts": audit_ts,
|
||||||
|
})
|
||||||
|
return self.get_wallet(wallet_address)
|
||||||
|
|
||||||
def probationary_jobs_remaining(self, wallet_address: str) -> int:
|
def probationary_jobs_remaining(self, wallet_address: str) -> int:
|
||||||
wallet = self.get_wallet(wallet_address)
|
wallet = self.get_wallet(wallet_address)
|
||||||
return max(0, self._state.probationary_job_count - wallet.completed_job_count)
|
return max(0, self._state.probationary_job_count - wallet.completed_job_count)
|
||||||
|
|
||||||
|
def record_audit_outcome(self, wallet_address: str, *, passed: bool, ts: float | None = None) -> RegistryWallet:
|
||||||
|
"""ADR-0018 §6: the only reputation signal — clean audits build score
|
||||||
|
slowly, a failed audit loses it instantly. Strikes/bans are recorded
|
||||||
|
separately (`record_strike` / `submit_slash_proof`) so a single
|
||||||
|
failed audit is never double-counted as two strikes."""
|
||||||
|
self._event_log.record({
|
||||||
|
"id": f"audit-outcome-{uuid.uuid4().hex}",
|
||||||
|
"type": "audit_outcome",
|
||||||
|
"wallet": wallet_address,
|
||||||
|
"passed": passed,
|
||||||
|
"ts": time.time() if ts is None else ts,
|
||||||
|
})
|
||||||
|
return self.get_wallet(wallet_address)
|
||||||
|
|
||||||
|
def routing_multiplier(self, wallet_address: str) -> float:
|
||||||
|
"""ADR-0018 §6: ×0.8 routing/payout weight per strike, separate from
|
||||||
|
the forfeiture penalty (which stays full pending balance)."""
|
||||||
|
return self.get_wallet(wallet_address).routing_multiplier
|
||||||
|
|
||||||
|
def apply_inactivity_decay(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
idle_seconds: float = REPUTATION_INACTIVITY_SECONDS,
|
||||||
|
decay_amount: float = REPUTATION_INACTIVITY_DECAY,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> dict[str, RegistryWallet]:
|
||||||
|
"""ADR-0018 §6: reputation decays for wallets with no completed job
|
||||||
|
in `idle_seconds`. Decaying resets the idle clock, so a wallet decays
|
||||||
|
at most once per idle window rather than every call while still idle."""
|
||||||
|
current_ts = time.time() if now is None else now
|
||||||
|
decayed: dict[str, RegistryWallet] = {}
|
||||||
|
for wallet_address, wallet in list(self._state.registry.items()):
|
||||||
|
if wallet.banned or wallet.last_active_ts is None:
|
||||||
|
continue
|
||||||
|
if current_ts - wallet.last_active_ts < idle_seconds:
|
||||||
|
continue
|
||||||
|
self._event_log.record({
|
||||||
|
"id": f"inactivity-decay-{uuid.uuid4().hex}",
|
||||||
|
"type": "inactivity_decay",
|
||||||
|
"wallet": wallet_address,
|
||||||
|
"amount": decay_amount,
|
||||||
|
"ts": current_ts,
|
||||||
|
})
|
||||||
|
decayed[wallet_address] = self.get_wallet(wallet_address)
|
||||||
|
return decayed
|
||||||
|
|
||||||
|
|
||||||
class ValidationContract:
|
class ValidationContract:
|
||||||
"""Validation event log consumed by the optimistic fraud detector."""
|
"""Validation event log consumed by the optimistic fraud detector."""
|
||||||
@@ -224,6 +474,7 @@ class ValidationContract:
|
|||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
observed_output: str,
|
observed_output: str,
|
||||||
route_nodes: list[dict],
|
route_nodes: list[dict],
|
||||||
|
ts: float | None = None,
|
||||||
) -> ValidationEvent:
|
) -> ValidationEvent:
|
||||||
if not session_id:
|
if not session_id:
|
||||||
raise ValueError("session_id is required")
|
raise ValueError("session_id is required")
|
||||||
@@ -242,6 +493,7 @@ class ValidationContract:
|
|||||||
messages=[dict(message) for message in messages],
|
messages=[dict(message) for message in messages],
|
||||||
observed_output=observed_output,
|
observed_output=observed_output,
|
||||||
route_nodes=[dict(node) for node in route_nodes],
|
route_nodes=[dict(node) for node in route_nodes],
|
||||||
|
**({"ts": ts} if ts is not None else {}),
|
||||||
)
|
)
|
||||||
self._state.validation_events.append(event)
|
self._state.validation_events.append(event)
|
||||||
return event
|
return event
|
||||||
@@ -331,10 +583,12 @@ class SettlementContract:
|
|||||||
state: _LocalContractState,
|
state: _LocalContractState,
|
||||||
cluster: str,
|
cluster: str,
|
||||||
cost_per_layer_token_lamport: int,
|
cost_per_layer_token_lamport: int,
|
||||||
|
registry: RegistryContract,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._state = state
|
self._state = state
|
||||||
self._cluster = cluster
|
self._cluster = cluster
|
||||||
self._cost_per_layer_token_lamport = cost_per_layer_token_lamport
|
self._cost_per_layer_token_lamport = cost_per_layer_token_lamport
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
def settle_epoch(
|
def settle_epoch(
|
||||||
self,
|
self,
|
||||||
@@ -423,13 +677,7 @@ class SettlementContract:
|
|||||||
return self._state.token_balances.get(wallet_address, 0)
|
return self._state.token_balances.get(wallet_address, 0)
|
||||||
|
|
||||||
def _record_completed_jobs(self, wallet_address: str, count: int) -> None:
|
def _record_completed_jobs(self, wallet_address: str, count: int) -> None:
|
||||||
current = self._state.registry.get(wallet_address, RegistryWallet())
|
self._registry.record_completed_jobs(wallet_address, count)
|
||||||
self._state.registry[wallet_address] = RegistryWallet(
|
|
||||||
stake_balance=current.stake_balance,
|
|
||||||
strike_count=current.strike_count,
|
|
||||||
banned=current.banned,
|
|
||||||
completed_job_count=current.completed_job_count + count,
|
|
||||||
)
|
|
||||||
|
|
||||||
def deployment_plan(self) -> dict:
|
def deployment_plan(self) -> dict:
|
||||||
"""Return the configured manual testnet deployment targets."""
|
"""Return the configured manual testnet deployment targets."""
|
||||||
@@ -449,6 +697,7 @@ class LocalSolanaContracts:
|
|||||||
cost_per_layer_token_lamport: int = 1,
|
cost_per_layer_token_lamport: int = 1,
|
||||||
starting_credit_lamports: int = 1_000,
|
starting_credit_lamports: int = 1_000,
|
||||||
probationary_job_count: int = 50,
|
probationary_job_count: int = 50,
|
||||||
|
registry_db: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if cost_per_layer_token_lamport <= 0:
|
if cost_per_layer_token_lamport <= 0:
|
||||||
raise ValueError("cost_per_layer_token_lamport must be positive")
|
raise ValueError("cost_per_layer_token_lamport must be positive")
|
||||||
@@ -459,15 +708,20 @@ class LocalSolanaContracts:
|
|||||||
self.cluster = cluster
|
self.cluster = cluster
|
||||||
self._state = _LocalContractState()
|
self._state = _LocalContractState()
|
||||||
self._state.probationary_job_count = probationary_job_count
|
self._state.probationary_job_count = probationary_job_count
|
||||||
self.registry = RegistryContract(self._state, cluster)
|
self.registry_log = RegistryEventLog(self._state, registry_db)
|
||||||
|
self.registry = RegistryContract(self._state, cluster, self.registry_log)
|
||||||
self.validation = ValidationContract(self._state)
|
self.validation = ValidationContract(self._state)
|
||||||
self.payment = PaymentContract(self._state, cluster, starting_credit_lamports)
|
self.payment = PaymentContract(self._state, cluster, starting_credit_lamports)
|
||||||
self.settlement = SettlementContract(
|
self.settlement = SettlementContract(
|
||||||
self._state,
|
self._state,
|
||||||
cluster,
|
cluster,
|
||||||
cost_per_layer_token_lamport,
|
cost_per_layer_token_lamport,
|
||||||
|
self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def save_to_db(self) -> None:
|
||||||
|
self.registry_log.save_to_db()
|
||||||
|
|
||||||
|
|
||||||
def _notify_slash(receipt: SlashReceipt, webhook_url: str | None) -> None:
|
def _notify_slash(receipt: SlashReceipt, webhook_url: str | None) -> None:
|
||||||
message = (
|
message = (
|
||||||
@@ -503,7 +757,15 @@ __all__ = [
|
|||||||
"ComputeAttribution",
|
"ComputeAttribution",
|
||||||
"LocalSolanaContracts",
|
"LocalSolanaContracts",
|
||||||
"PaymentContract",
|
"PaymentContract",
|
||||||
|
"REPUTATION_CLEAN_AUDIT_DELTA",
|
||||||
|
"REPUTATION_FAILED_AUDIT_DELTA",
|
||||||
|
"REPUTATION_INACTIVITY_DECAY",
|
||||||
|
"REPUTATION_INACTIVITY_SECONDS",
|
||||||
|
"REPUTATION_MAX",
|
||||||
|
"REPUTATION_MIN",
|
||||||
|
"ROUTING_STRIKE_MULTIPLIER",
|
||||||
"RegistryContract",
|
"RegistryContract",
|
||||||
|
"RegistryEventLog",
|
||||||
"RegistryWallet",
|
"RegistryWallet",
|
||||||
"SettlementContract",
|
"SettlementContract",
|
||||||
"SettlementResult",
|
"SettlementResult",
|
||||||
|
|||||||
@@ -3,12 +3,45 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(path: Path) -> None:
|
||||||
|
"""Load simple KEY=VALUE pairs from an env file without overriding env vars."""
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
lines = path.read_text().splitlines()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
for line in lines:
|
||||||
|
text = line.strip()
|
||||||
|
if not text or text.startswith("#"):
|
||||||
|
continue
|
||||||
|
if text.startswith("export "):
|
||||||
|
text = text[len("export "):].strip()
|
||||||
|
if "=" not in text:
|
||||||
|
continue
|
||||||
|
key, value = text.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
if not key or key in os.environ:
|
||||||
|
continue
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||||
|
value = value[1:-1]
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_defaults() -> None:
|
||||||
|
"""Load local and user-level node env defaults before config defaults are imported."""
|
||||||
|
_load_env_file(Path.cwd() / ".env")
|
||||||
|
_load_env_file(Path.home() / ".config" / "meshnet" / "secrets.env")
|
||||||
|
|
||||||
|
|
||||||
def _run_node(cfg: dict) -> None:
|
def _run_node(cfg: dict) -> None:
|
||||||
"""Start the node and hand off to the live dashboard. Blocks until Ctrl-C."""
|
"""Start the node and hand off to the live dashboard. Blocks until Ctrl-C."""
|
||||||
from .startup import run_startup
|
from .startup import run_startup
|
||||||
@@ -19,11 +52,11 @@ def _run_node(cfg: dict) -> None:
|
|||||||
node = run_startup(
|
node = run_startup(
|
||||||
tracker_url=cfg["tracker_url"],
|
tracker_url=cfg["tracker_url"],
|
||||||
port=cfg.get("port", 7000),
|
port=cfg.get("port", 7000),
|
||||||
model=cfg.get("model_name") or "stub-model",
|
model=cfg.get("model_name") or None,
|
||||||
model_id=cfg.get("model_hf_repo") or None,
|
model_id=cfg.get("model_hf_repo") or None,
|
||||||
shard_start=cfg.get("shard_start"),
|
shard_start=cfg.get("shard_start"),
|
||||||
shard_end=cfg.get("shard_end"),
|
shard_end=cfg.get("shard_end"),
|
||||||
quantization=cfg.get("quantization", "int8").replace("bf16", "bfloat16"),
|
quantization=cfg.get("quantization", "auto").replace("bf16", "bfloat16"),
|
||||||
wallet_path=Path(cfg["wallet_path"]) if cfg.get("wallet_path") else None,
|
wallet_path=Path(cfg["wallet_path"]) if cfg.get("wallet_path") else None,
|
||||||
cache_dir=Path(cfg["download_dir"]) if cfg.get("download_dir") else None,
|
cache_dir=Path(cfg["download_dir"]) if cfg.get("download_dir") else None,
|
||||||
host=cfg.get("host", "0.0.0.0"),
|
host=cfg.get("host", "0.0.0.0"),
|
||||||
@@ -32,6 +65,9 @@ def _run_node(cfg: dict) -> None:
|
|||||||
vram_mb_override=cfg.get("vram_mb_override"),
|
vram_mb_override=cfg.get("vram_mb_override"),
|
||||||
max_loaded_shards=int(cfg.get("max_loaded_shards", 1)),
|
max_loaded_shards=int(cfg.get("max_loaded_shards", 1)),
|
||||||
debug=bool(cfg.get("debug", False)),
|
debug=bool(cfg.get("debug", False)),
|
||||||
|
tracker_source_disabled=bool(cfg.get("tracker_source_disabled", False)),
|
||||||
|
torch_threads=cfg.get("torch_threads"),
|
||||||
|
torch_interop_threads=cfg.get("torch_interop_threads"),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"\nERROR: {exc}", file=sys.stderr, flush=True)
|
print(f"\nERROR: {exc}", file=sys.stderr, flush=True)
|
||||||
@@ -54,6 +90,19 @@ def _run_node(cfg: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_model_flags(
|
||||||
|
model: str | None,
|
||||||
|
model_id: str | None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Return (model_name, hf_repo_or_none) from --model / --model-id flags."""
|
||||||
|
explicit = model_id or model
|
||||||
|
if not explicit:
|
||||||
|
return None, None
|
||||||
|
if "/" in explicit:
|
||||||
|
return explicit.split("/")[-1], explicit
|
||||||
|
return explicit, None
|
||||||
|
|
||||||
|
|
||||||
def _first_available_port(host: str, start: int = 7000, attempts: int = 100) -> int:
|
def _first_available_port(host: str, start: int = 7000, attempts: int = 100) -> int:
|
||||||
"""Return the first TCP port bindable on host, starting at start."""
|
"""Return the first TCP port bindable on host, starting at start."""
|
||||||
bind_host = "" if host == "0.0.0.0" else host
|
bind_host = "" if host == "0.0.0.0" else host
|
||||||
@@ -86,9 +135,10 @@ def _cmd_default(args) -> int:
|
|||||||
|
|
||||||
# Apply CLI overrides on top of saved config
|
# Apply CLI overrides on top of saved config
|
||||||
overrides: dict = {}
|
overrides: dict = {}
|
||||||
if args.model:
|
model_name, hf_repo = _resolve_model_flags(args.model, getattr(args, "model_id", None))
|
||||||
overrides["model_hf_repo"] = args.model
|
if model_name is not None:
|
||||||
overrides["model_name"] = args.model.split("/")[-1]
|
overrides["model_name"] = model_name
|
||||||
|
overrides["model_hf_repo"] = hf_repo or ""
|
||||||
if args.quantization:
|
if args.quantization:
|
||||||
overrides["quantization"] = args.quantization
|
overrides["quantization"] = args.quantization
|
||||||
if args.download_dir:
|
if args.download_dir:
|
||||||
@@ -115,6 +165,12 @@ def _cmd_default(args) -> int:
|
|||||||
overrides["max_loaded_shards"] = args.max_shards
|
overrides["max_loaded_shards"] = args.max_shards
|
||||||
if args.debug:
|
if args.debug:
|
||||||
overrides["debug"] = True
|
overrides["debug"] = True
|
||||||
|
if getattr(args, "tracker_source_disabled", False):
|
||||||
|
overrides["tracker_source_disabled"] = True
|
||||||
|
if getattr(args, "torch_threads", None) is not None:
|
||||||
|
overrides["torch_threads"] = args.torch_threads
|
||||||
|
if getattr(args, "torch_interop_threads", None) is not None:
|
||||||
|
overrides["torch_interop_threads"] = args.torch_interop_threads
|
||||||
|
|
||||||
if overrides:
|
if overrides:
|
||||||
cfg = merge_cli_overrides(cfg, **overrides)
|
cfg = merge_cli_overrides(cfg, **overrides)
|
||||||
@@ -166,21 +222,22 @@ def _cmd_config(args) -> int:
|
|||||||
|
|
||||||
def _cmd_start(args) -> int:
|
def _cmd_start(args) -> int:
|
||||||
"""Legacy `start` subcommand — preserves backward compatibility with existing tests."""
|
"""Legacy `start` subcommand — preserves backward compatibility with existing tests."""
|
||||||
from .config import load_config, DEFAULTS
|
from .config import DEFAULTS
|
||||||
|
|
||||||
# Build a transient config from flags (don't write to disk)
|
# Build a transient config from flags (don't write to disk)
|
||||||
cfg = dict(DEFAULTS)
|
cfg = dict(DEFAULTS)
|
||||||
|
if args.tracker:
|
||||||
cfg["tracker_url"] = args.tracker
|
cfg["tracker_url"] = args.tracker
|
||||||
cfg["port"] = args.port if args.port is not None else _first_available_port(args.host)
|
cfg["port"] = args.port if args.port is not None else _first_available_port(args.host)
|
||||||
if args.model_id is None and "/" in args.model:
|
model_name, hf_repo = _resolve_model_flags(
|
||||||
cfg["model_hf_repo"] = args.model
|
args.model or cfg.get("model_hf_repo") or cfg.get("model_name") or None,
|
||||||
cfg["model_name"] = args.model.split("/")[-1]
|
args.model_id,
|
||||||
else:
|
)
|
||||||
cfg["model_name"] = args.model
|
if model_name is not None:
|
||||||
|
cfg["model_name"] = model_name
|
||||||
|
cfg["model_hf_repo"] = hf_repo or ""
|
||||||
cfg["quantization"] = args.quantization
|
cfg["quantization"] = args.quantization
|
||||||
cfg["host"] = args.host
|
cfg["host"] = args.host
|
||||||
if args.model_id:
|
|
||||||
cfg["model_hf_repo"] = args.model_id
|
|
||||||
if args.shard_start is not None:
|
if args.shard_start is not None:
|
||||||
cfg["shard_start"] = args.shard_start
|
cfg["shard_start"] = args.shard_start
|
||||||
if args.shard_end is not None:
|
if args.shard_end is not None:
|
||||||
@@ -198,7 +255,7 @@ def _cmd_start(args) -> int:
|
|||||||
tracker_url=cfg["tracker_url"],
|
tracker_url=cfg["tracker_url"],
|
||||||
port=cfg["port"],
|
port=cfg["port"],
|
||||||
model=cfg["model_name"],
|
model=cfg["model_name"],
|
||||||
model_id=cfg.get("model_hf_repo"),
|
model_id=cfg.get("model_hf_repo") or None,
|
||||||
shard_start=cfg.get("shard_start"),
|
shard_start=cfg.get("shard_start"),
|
||||||
shard_end=cfg.get("shard_end"),
|
shard_end=cfg.get("shard_end"),
|
||||||
quantization=cfg["quantization"].replace("bf16", "bfloat16"),
|
quantization=cfg["quantization"].replace("bf16", "bfloat16"),
|
||||||
@@ -210,6 +267,9 @@ def _cmd_start(args) -> int:
|
|||||||
vram_mb_override=getattr(args, "memory", None),
|
vram_mb_override=getattr(args, "memory", None),
|
||||||
max_loaded_shards=getattr(args, "max_shards", 1),
|
max_loaded_shards=getattr(args, "max_shards", 1),
|
||||||
debug=getattr(args, "debug", False),
|
debug=getattr(args, "debug", False),
|
||||||
|
tracker_source_disabled=getattr(args, "tracker_source_disabled", False),
|
||||||
|
torch_threads=getattr(args, "torch_threads", None),
|
||||||
|
torch_interop_threads=getattr(args, "torch_interop_threads", None),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"ERROR: {exc}", file=sys.stderr, flush=True)
|
print(f"ERROR: {exc}", file=sys.stderr, flush=True)
|
||||||
@@ -224,6 +284,8 @@ def _cmd_start(args) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
_load_env_defaults()
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="meshnet-node",
|
prog="meshnet-node",
|
||||||
description="Distributed AI Inference — Node Client",
|
description="Distributed AI Inference — Node Client",
|
||||||
@@ -239,11 +301,14 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Flags that apply to the no-subcommand (default) path
|
# Flags that apply to the no-subcommand (default) path
|
||||||
parser.add_argument("--model", metavar="HF_REPO", help="HuggingFace repo ID to serve")
|
parser.add_argument("--model", metavar="MODEL", help="Model name or HuggingFace repo ID to serve")
|
||||||
|
parser.add_argument("--model-id", metavar="MODEL", help="Alias for --model (catalog name or HuggingFace repo)")
|
||||||
parser.add_argument("--quantization", "-q", choices=["bf16", "int8", "nf4", "bfloat16"],
|
parser.add_argument("--quantization", "-q", choices=["bf16", "int8", "nf4", "bfloat16"],
|
||||||
help="Quantization level")
|
help="Quantization level")
|
||||||
parser.add_argument("--download-dir", metavar="PATH", help="Model download directory")
|
parser.add_argument("--download-dir", metavar="PATH", help="Model download directory")
|
||||||
parser.add_argument("--tracker", metavar="URL", help="Tracker URL")
|
parser.add_argument("--tracker", metavar="URL", help="Tracker URL")
|
||||||
|
parser.add_argument("--tracker-source-disabled", action="store_true",
|
||||||
|
help="Skip tracker/peer model-file sources and download from HuggingFace directly")
|
||||||
parser.add_argument("--wallet", metavar="PATH", help="Wallet file path")
|
parser.add_argument("--wallet", metavar="PATH", help="Wallet file path")
|
||||||
parser.add_argument("--shard-start", type=int, metavar="N", help="Pin shard start layer")
|
parser.add_argument("--shard-start", type=int, metavar="N", help="Pin shard start layer")
|
||||||
parser.add_argument("--shard-end", type=int, metavar="N", help="Pin shard end layer")
|
parser.add_argument("--shard-end", type=int, metavar="N", help="Pin shard end layer")
|
||||||
@@ -256,6 +321,10 @@ def main() -> None:
|
|||||||
help="Override autodetected VRAM/RAM budget in MB used for shard assignment")
|
help="Override autodetected VRAM/RAM budget in MB used for shard assignment")
|
||||||
parser.add_argument("--max-shards", type=int, metavar="N", default=None,
|
parser.add_argument("--max-shards", type=int, metavar="N", default=None,
|
||||||
help="Maximum shard slots this node advertises to the tracker (default 1)")
|
help="Maximum shard slots this node advertises to the tracker (default 1)")
|
||||||
|
parser.add_argument("--torch-threads", type=int, metavar="N",
|
||||||
|
help="Set PyTorch intra-op CPU worker threads")
|
||||||
|
parser.add_argument("--torch-interop-threads", type=int, metavar="N",
|
||||||
|
help="Set PyTorch inter-op CPU worker threads")
|
||||||
parser.add_argument("--debug", action="store_true", help="Enable verbose node debug logging")
|
parser.add_argument("--debug", action="store_true", help="Enable verbose node debug logging")
|
||||||
parser.add_argument("--no-tui", action="store_true", help="Plain-text output (no rich dashboard)")
|
parser.add_argument("--no-tui", action="store_true", help="Plain-text output (no rich dashboard)")
|
||||||
parser.add_argument("--compact", action="store_true", help="Single-line status output")
|
parser.add_argument("--compact", action="store_true", help="Single-line status output")
|
||||||
@@ -272,13 +341,13 @@ def main() -> None:
|
|||||||
|
|
||||||
# start subcommand (legacy / backward-compat)
|
# start subcommand (legacy / backward-compat)
|
||||||
start_cmd = subparsers.add_parser("start", help="Start node (legacy flags)")
|
start_cmd = subparsers.add_parser("start", help="Start node (legacy flags)")
|
||||||
start_cmd.add_argument("--tracker", default="http://localhost:8080")
|
start_cmd.add_argument("--tracker")
|
||||||
start_cmd.add_argument("--port", type=int)
|
start_cmd.add_argument("--port", type=int)
|
||||||
start_cmd.add_argument("--model", default="stub-model")
|
start_cmd.add_argument("--model", help="Model name or HuggingFace repo ID")
|
||||||
start_cmd.add_argument("--model-id", help="HuggingFace repo ID")
|
start_cmd.add_argument("--model-id", help="Alias for --model (catalog name or HuggingFace repo)")
|
||||||
start_cmd.add_argument("--shard-start", type=int)
|
start_cmd.add_argument("--shard-start", type=int)
|
||||||
start_cmd.add_argument("--shard-end", type=int)
|
start_cmd.add_argument("--shard-end", type=int)
|
||||||
start_cmd.add_argument("--quantization", choices=["bfloat16", "int8", "nf4", "bf16"], default="int8")
|
start_cmd.add_argument("--quantization", choices=["auto", "bfloat16", "int8", "nf4", "bf16"], default="auto")
|
||||||
start_cmd.add_argument("--host", default="0.0.0.0")
|
start_cmd.add_argument("--host", default="0.0.0.0")
|
||||||
start_cmd.add_argument("--advertise-host")
|
start_cmd.add_argument("--advertise-host")
|
||||||
start_cmd.add_argument("--tracker-mode", action="store_true")
|
start_cmd.add_argument("--tracker-mode", action="store_true")
|
||||||
@@ -291,7 +360,13 @@ def main() -> None:
|
|||||||
help="Override autodetected VRAM/RAM budget in MB used for shard assignment")
|
help="Override autodetected VRAM/RAM budget in MB used for shard assignment")
|
||||||
start_cmd.add_argument("--max-shards", type=int, default=1, metavar="N",
|
start_cmd.add_argument("--max-shards", type=int, default=1, metavar="N",
|
||||||
help="Maximum shard slots this node advertises to the tracker (default 1)")
|
help="Maximum shard slots this node advertises to the tracker (default 1)")
|
||||||
|
start_cmd.add_argument("--torch-threads", type=int, metavar="N",
|
||||||
|
help="Set PyTorch intra-op CPU worker threads")
|
||||||
|
start_cmd.add_argument("--torch-interop-threads", type=int, metavar="N",
|
||||||
|
help="Set PyTorch inter-op CPU worker threads")
|
||||||
start_cmd.add_argument("--debug", action="store_true", help="Enable verbose node debug logging")
|
start_cmd.add_argument("--debug", action="store_true", help="Enable verbose node debug logging")
|
||||||
|
start_cmd.add_argument("--tracker-source-disabled", action="store_true",
|
||||||
|
help="Skip tracker/peer model-file sources and download from HuggingFace directly")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,21 @@ from pathlib import Path
|
|||||||
|
|
||||||
_DEFAULT_CONFIG_DIR = Path.home() / ".config" / "meshnet"
|
_DEFAULT_CONFIG_DIR = Path.home() / ".config" / "meshnet"
|
||||||
_DEFAULT_CONFIG_FILE = _DEFAULT_CONFIG_DIR / "config.json"
|
_DEFAULT_CONFIG_FILE = _DEFAULT_CONFIG_DIR / "config.json"
|
||||||
_DEFAULT_DOWNLOAD_DIR = Path.home() / ".meshnet" / "models"
|
# MESHNET_DOWNLOAD_DIR overrides the model store for every node on this
|
||||||
_DEFAULT_TRACKER_URL = "http://localhost:8080"
|
# machine (all CLI flows fall back to this default; --download-dir still wins).
|
||||||
|
_DEFAULT_DOWNLOAD_DIR = Path(
|
||||||
|
os.environ.get("MESHNET_DOWNLOAD_DIR", str(Path.home() / ".meshnet" / "models"))
|
||||||
|
)
|
||||||
|
_DEFAULT_TRACKER_URL = os.environ.get("MESHNET_TRACKER_URL", "http://localhost:8080")
|
||||||
_DEFAULT_WALLET_PATH = str(Path.home() / ".config" / "meshnet" / "wallet.json")
|
_DEFAULT_WALLET_PATH = str(Path.home() / ".config" / "meshnet" / "wallet.json")
|
||||||
_DEFAULT_QUANTIZATION = "nf4"
|
_DEFAULT_QUANTIZATION = "auto"
|
||||||
|
_DEFAULT_MODEL = os.environ.get("MESHNET_MODEL_ID") or os.environ.get("MESHNET_MODEL", "")
|
||||||
|
_DEFAULT_MODEL_HF_REPO = _DEFAULT_MODEL if "/" in _DEFAULT_MODEL else ""
|
||||||
|
_DEFAULT_MODEL_NAME = _DEFAULT_MODEL.split("/")[-1] if "/" in _DEFAULT_MODEL else _DEFAULT_MODEL
|
||||||
|
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"model_hf_repo": "",
|
"model_hf_repo": _DEFAULT_MODEL_HF_REPO,
|
||||||
"model_name": "",
|
"model_name": _DEFAULT_MODEL_NAME,
|
||||||
"quantization": _DEFAULT_QUANTIZATION,
|
"quantization": _DEFAULT_QUANTIZATION,
|
||||||
"download_dir": str(_DEFAULT_DOWNLOAD_DIR),
|
"download_dir": str(_DEFAULT_DOWNLOAD_DIR),
|
||||||
"tracker_url": _DEFAULT_TRACKER_URL,
|
"tracker_url": _DEFAULT_TRACKER_URL,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Shard downloader — fetches model shards from peers or HuggingFace Hub.
|
"""Shard downloader — fetches model files from peers, tracker sources, or HuggingFace.
|
||||||
|
|
||||||
Cache layout: ~/.cache/meshnet/shards/<model>/layers_<start>-<end>/
|
Cache layout: ~/.cache/meshnet/shards/<model>/
|
||||||
|
|
||||||
For "stub-model" (no HF repo), a placeholder JSON file is written so the
|
For "stub-model" (no HF repo), a placeholder JSON file is written so the
|
||||||
test suite never touches the network.
|
test suite never touches the network.
|
||||||
@@ -8,9 +8,11 @@ test suite never touches the network.
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -18,6 +20,12 @@ from typing import Any
|
|||||||
|
|
||||||
_DEFAULT_CACHE = Path.home() / ".cache" / "meshnet" / "shards"
|
_DEFAULT_CACHE = Path.home() / ".cache" / "meshnet" / "shards"
|
||||||
_PEER_TIMEOUT_SECONDS = 2.0
|
_PEER_TIMEOUT_SECONDS = 2.0
|
||||||
|
# Model-source tar streams are multi-GB; a short socket timeout must not kill
|
||||||
|
# them on a transient read stall. Peer probes keep the short timeout because
|
||||||
|
# they run sequentially before the race and may hit dead endpoints.
|
||||||
|
_MODEL_SOURCE_TIMEOUT_SECONDS = 30.0
|
||||||
|
_PROGRESS_INTERVAL_BYTES = 512 * 1024 * 1024
|
||||||
|
_FILE_RETRY_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
def compute_shard_checksum(shard_dir: Path) -> str:
|
def compute_shard_checksum(shard_dir: Path) -> str:
|
||||||
@@ -36,7 +44,8 @@ def compute_shard_checksum(shard_dir: Path) -> str:
|
|||||||
|
|
||||||
def write_shard_archive(shard_dir: Path, out_file: Any) -> None:
|
def write_shard_archive(shard_dir: Path, out_file: Any) -> None:
|
||||||
"""Write a tar archive for *shard_dir* to a binary file-like object."""
|
"""Write a tar archive for *shard_dir* to a binary file-like object."""
|
||||||
with tarfile.open(fileobj=out_file, mode="w|") as archive:
|
# dereference: HF cache snapshots are symlinks into blobs/ — ship contents.
|
||||||
|
with tarfile.open(fileobj=out_file, mode="w|", dereference=True) as archive:
|
||||||
for path in sorted(p for p in shard_dir.rglob("*") if p.is_file()):
|
for path in sorted(p for p in shard_dir.rglob("*") if p.is_file()):
|
||||||
archive.add(path, arcname=path.relative_to(shard_dir).as_posix())
|
archive.add(path, arcname=path.relative_to(shard_dir).as_posix())
|
||||||
|
|
||||||
@@ -97,14 +106,340 @@ def _download_shard_from_peer(
|
|||||||
_safe_extract_shard(archive_path, extract_dir)
|
_safe_extract_shard(archive_path, extract_dir)
|
||||||
if compute_shard_checksum(extract_dir) != checksum:
|
if compute_shard_checksum(extract_dir) != checksum:
|
||||||
return False
|
return False
|
||||||
if shard_dir.exists():
|
_merge_tree(extract_dir, shard_dir)
|
||||||
shutil.rmtree(shard_dir)
|
|
||||||
shutil.move(str(extract_dir), str(shard_dir))
|
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _TarOnlySource(Exception):
|
||||||
|
"""The server ignored ?file= and streamed a tar — no single-file support."""
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_source_file(
|
||||||
|
file_url: str,
|
||||||
|
dest: Path,
|
||||||
|
timeout: float,
|
||||||
|
on_chunk=None,
|
||||||
|
) -> int:
|
||||||
|
"""Download one file; skip if *dest* already matches the remote size.
|
||||||
|
|
||||||
|
Returns the file's final size. Raises on any failure, including a
|
||||||
|
short read against the server's Content-Length.
|
||||||
|
"""
|
||||||
|
with urllib.request.urlopen(file_url, timeout=timeout) as resp:
|
||||||
|
ctype = resp.getheader("Content-Type") if hasattr(resp, "getheader") else None
|
||||||
|
if isinstance(ctype, str) and "x-tar" in ctype:
|
||||||
|
raise _TarOnlySource(file_url)
|
||||||
|
length = resp.getheader("Content-Length") if hasattr(resp, "getheader") else None
|
||||||
|
expected = int(length) if isinstance(length, str) and length.isdigit() else None
|
||||||
|
if expected is not None and dest.exists() and dest.stat().st_size == expected:
|
||||||
|
if on_chunk is not None:
|
||||||
|
on_chunk(expected)
|
||||||
|
return expected # complete from an earlier attempt/run
|
||||||
|
received = 0
|
||||||
|
with dest.open("wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = resp.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out.write(chunk)
|
||||||
|
received += len(chunk)
|
||||||
|
if on_chunk is not None:
|
||||||
|
on_chunk(len(chunk))
|
||||||
|
if expected is not None and received != expected:
|
||||||
|
raise OSError(f"short read: got {received} of {expected} bytes")
|
||||||
|
return received
|
||||||
|
|
||||||
|
|
||||||
|
class _SourceProgress:
|
||||||
|
"""tqdm bar over the whole per-file download (total / speed / ETA),
|
||||||
|
falling back to plain per-file prints when tqdm is unavailable."""
|
||||||
|
|
||||||
|
def __init__(self, enabled: bool, label: str, total_bytes: int | None):
|
||||||
|
self._label = label
|
||||||
|
self._bar = None
|
||||||
|
self._enabled = enabled
|
||||||
|
if enabled and total_bytes:
|
||||||
|
try:
|
||||||
|
from tqdm import tqdm # type: ignore[import]
|
||||||
|
|
||||||
|
self._bar = tqdm(
|
||||||
|
total=total_bytes,
|
||||||
|
unit="B",
|
||||||
|
unit_scale=True,
|
||||||
|
unit_divisor=1024,
|
||||||
|
desc=f"Downloading ({label})",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._bar = None
|
||||||
|
|
||||||
|
def add_bytes(self, n: int) -> None:
|
||||||
|
if self._bar is not None:
|
||||||
|
self._bar.update(n)
|
||||||
|
|
||||||
|
def rewind(self, n: int) -> None:
|
||||||
|
"""Roll the bar back after a failed attempt so retries don't double-count."""
|
||||||
|
if self._bar is not None and n:
|
||||||
|
self._bar.update(-n)
|
||||||
|
|
||||||
|
def file_done(self, index: int, count: int, rel: str, size: int, reused: bool) -> None:
|
||||||
|
note = "already complete, " if reused else ""
|
||||||
|
line = f" {self._label}: [{index}/{count}] {rel} ({note}{size / 1e9:.2f} GB)"
|
||||||
|
if self._bar is not None:
|
||||||
|
self._bar.set_postfix_str(f"{index}/{count} files", refresh=False)
|
||||||
|
elif self._enabled:
|
||||||
|
print(line, flush=True)
|
||||||
|
|
||||||
|
def message(self, text: str) -> None:
|
||||||
|
if self._bar is not None:
|
||||||
|
self._bar.write(text)
|
||||||
|
elif self._enabled:
|
||||||
|
print(text, flush=True)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._bar is not None:
|
||||||
|
self._bar.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _reuse_local_file(expected: int, dest: Path, final: Path) -> bool:
|
||||||
|
"""Reuse an already-complete copy of a file instead of re-downloading."""
|
||||||
|
if dest.exists() and dest.stat().st_size == expected:
|
||||||
|
return True
|
||||||
|
if final.exists() and final.stat().st_size == expected:
|
||||||
|
try:
|
||||||
|
os.link(final, dest)
|
||||||
|
except OSError:
|
||||||
|
shutil.copy2(final, dest)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_source_rel_files(source: dict) -> list[str]:
|
||||||
|
return [
|
||||||
|
rel for rel in (source.get("files") or [])
|
||||||
|
if isinstance(rel, str) and rel and not rel.startswith("/") and ".." not in Path(rel).parts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _source_files_cached(source: dict, shard_dir: Path) -> bool:
|
||||||
|
rel_files = _valid_source_rel_files(source)
|
||||||
|
if not rel_files:
|
||||||
|
return False
|
||||||
|
sizes = source.get("file_sizes")
|
||||||
|
if not isinstance(sizes, dict):
|
||||||
|
return False
|
||||||
|
for rel in rel_files:
|
||||||
|
expected = sizes.get(rel)
|
||||||
|
path = shard_dir / rel
|
||||||
|
if not isinstance(expected, int) or not path.exists() or path.stat().st_size != expected:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_tree(src: Path, dest: Path) -> None:
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
for path in sorted(p for p in src.rglob("*") if p.is_file()):
|
||||||
|
target = dest / path.relative_to(src)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.exists():
|
||||||
|
target.unlink()
|
||||||
|
shutil.move(str(path), str(target))
|
||||||
|
|
||||||
|
|
||||||
|
def _download_source_files(
|
||||||
|
source: dict,
|
||||||
|
shard_dir: Path,
|
||||||
|
timeout: float,
|
||||||
|
progress: bool,
|
||||||
|
label: str,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Per-file download from a model source — retries and resumes per file.
|
||||||
|
|
||||||
|
Far more robust than one multi-GB tar stream on flaky links: a dropped
|
||||||
|
connection costs at most one file, and completed files are skipped when
|
||||||
|
the download is retried or the node is restarted.
|
||||||
|
"""
|
||||||
|
url = source.get("url")
|
||||||
|
rel_files = _valid_source_rel_files(source)
|
||||||
|
if not isinstance(url, str) or not url or not rel_files:
|
||||||
|
return None
|
||||||
|
sizes = source.get("file_sizes")
|
||||||
|
if not isinstance(sizes, dict):
|
||||||
|
sizes = {}
|
||||||
|
partial_dir = shard_dir.parent / f"{shard_dir.name}.partial"
|
||||||
|
partial_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
sep = "&" if "?" in url else "?"
|
||||||
|
known_sizes = [sizes.get(rel) for rel in rel_files]
|
||||||
|
total_bytes = sum(s for s in known_sizes if isinstance(s, int)) if all(
|
||||||
|
isinstance(s, int) for s in known_sizes
|
||||||
|
) else None
|
||||||
|
tracker_bar = _SourceProgress(progress, label, total_bytes)
|
||||||
|
try:
|
||||||
|
for index, rel in enumerate(rel_files, start=1):
|
||||||
|
dest = partial_dir / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
known_size = sizes.get(rel)
|
||||||
|
if isinstance(known_size, int) and _reuse_local_file(known_size, dest, shard_dir / rel):
|
||||||
|
tracker_bar.add_bytes(known_size)
|
||||||
|
tracker_bar.file_done(index, len(rel_files), rel, known_size, reused=True)
|
||||||
|
continue
|
||||||
|
file_url = f"{url}{sep}{urllib.parse.urlencode({'file': rel})}"
|
||||||
|
for attempt in range(1, _FILE_RETRY_ATTEMPTS + 1):
|
||||||
|
counted = 0
|
||||||
|
|
||||||
|
def _on_chunk(n: int) -> None:
|
||||||
|
nonlocal counted
|
||||||
|
counted += n
|
||||||
|
tracker_bar.add_bytes(n)
|
||||||
|
|
||||||
|
try:
|
||||||
|
size = _fetch_source_file(file_url, dest, timeout, on_chunk=_on_chunk)
|
||||||
|
tracker_bar.file_done(index, len(rel_files), rel, size, reused=False)
|
||||||
|
break
|
||||||
|
except _TarOnlySource:
|
||||||
|
tracker_bar.message(f" {label}: no single-file support — using tar stream")
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
tracker_bar.rewind(counted)
|
||||||
|
tracker_bar.message(
|
||||||
|
f" {label}: {rel} attempt {attempt}/{_FILE_RETRY_ATTEMPTS} failed: {exc!r}"
|
||||||
|
)
|
||||||
|
if attempt == _FILE_RETRY_ATTEMPTS:
|
||||||
|
return None
|
||||||
|
time.sleep(1.0 * attempt)
|
||||||
|
finally:
|
||||||
|
tracker_bar.close()
|
||||||
|
_merge_tree(partial_dir, shard_dir)
|
||||||
|
try:
|
||||||
|
partial_dir.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return shard_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _download_model_source(
|
||||||
|
source: dict,
|
||||||
|
shard_dir: Path,
|
||||||
|
timeout: float,
|
||||||
|
progress: bool = False,
|
||||||
|
label: str = "model-source",
|
||||||
|
) -> Path | None:
|
||||||
|
# Prefer per-file transfers whenever the source advertises its file list;
|
||||||
|
# fall through to the tar stream if the server lacks single-file support
|
||||||
|
# or per-file transfers keep failing.
|
||||||
|
if source.get("files"):
|
||||||
|
fetched = _download_source_files(source, shard_dir, timeout, progress, label)
|
||||||
|
if fetched is not None:
|
||||||
|
return fetched
|
||||||
|
url = source.get("url")
|
||||||
|
if not isinstance(url, str) or not url:
|
||||||
|
endpoint = source.get("endpoint")
|
||||||
|
if not isinstance(endpoint, str):
|
||||||
|
return None
|
||||||
|
url = f"{endpoint.rstrip('/')}/v1/model-files/download"
|
||||||
|
shard_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="meshnet-model-source-", dir=shard_dir.parent) as tmp:
|
||||||
|
tmp_root = Path(tmp)
|
||||||
|
archive_path = tmp_root / "model-files.tar"
|
||||||
|
extract_dir = tmp_root / "extract"
|
||||||
|
extract_dir.mkdir()
|
||||||
|
try:
|
||||||
|
received = 0
|
||||||
|
next_report = _PROGRESS_INTERVAL_BYTES
|
||||||
|
with urllib.request.urlopen(url, timeout=timeout) as resp, archive_path.open("wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = resp.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out.write(chunk)
|
||||||
|
received += len(chunk)
|
||||||
|
if progress and received >= next_report:
|
||||||
|
print(f" {label}: {received / 1e9:.1f} GB received ...", flush=True)
|
||||||
|
next_report += _PROGRESS_INTERVAL_BYTES
|
||||||
|
if progress:
|
||||||
|
print(f" {label}: transfer complete ({received / 1e9:.2f} GB), extracting ...", flush=True)
|
||||||
|
_safe_extract_shard(archive_path, extract_dir)
|
||||||
|
_merge_tree(extract_dir, shard_dir)
|
||||||
|
return shard_dir
|
||||||
|
except Exception as exc:
|
||||||
|
if progress:
|
||||||
|
print(f" {label}: download failed ({url}): {exc!r}", flush=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _download_huggingface_subset(
|
||||||
|
hf_repo: str,
|
||||||
|
cache_dir: Path,
|
||||||
|
shard_dir: Path,
|
||||||
|
allow_patterns: list[str] | None,
|
||||||
|
) -> Path:
|
||||||
|
from huggingface_hub import snapshot_download # type: ignore[import]
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"repo_id": hf_repo,
|
||||||
|
"cache_dir": str(cache_dir),
|
||||||
|
"local_dir": str(shard_dir),
|
||||||
|
}
|
||||||
|
if allow_patterns:
|
||||||
|
kwargs["allow_patterns"] = allow_patterns
|
||||||
|
try:
|
||||||
|
return Path(snapshot_download(**kwargs))
|
||||||
|
except TypeError:
|
||||||
|
kwargs.pop("allow_patterns", None)
|
||||||
|
return Path(snapshot_download(**kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_patterns_from_sources(model_sources: list[dict]) -> list[str] | None:
|
||||||
|
patterns: set[str] = set()
|
||||||
|
for source in model_sources:
|
||||||
|
for rel in source.get("files") or []:
|
||||||
|
if isinstance(rel, str) and rel and not rel.startswith("/") and ".." not in Path(rel).parts:
|
||||||
|
patterns.add(rel)
|
||||||
|
return sorted(patterns) if patterns else None
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_patterns_from_remote_index(
|
||||||
|
hf_repo: str,
|
||||||
|
cache_dir: Path,
|
||||||
|
shard_start: int,
|
||||||
|
shard_end: int,
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Fetch just the SafeTensors index + config (a few KB) from HF and compute
|
||||||
|
which weight files the assigned layer range needs, so a HuggingFace fallback
|
||||||
|
download stays layer-scoped even when the tracker has no model_sources
|
||||||
|
(e.g. it has no local snapshot for this repo cached yet)."""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import hf_hub_download # type: ignore[import]
|
||||||
|
|
||||||
|
from .safetensors_selection import (
|
||||||
|
INDEX_FILENAME,
|
||||||
|
METADATA_FILENAMES,
|
||||||
|
layers_from_config_dict,
|
||||||
|
select_files_for_layers_from_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
index_path = hf_hub_download(repo_id=hf_repo, filename=INDEX_FILENAME, cache_dir=str(cache_dir))
|
||||||
|
weight_map = json.loads(Path(index_path).read_text(encoding="utf-8")).get("weight_map")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(weight_map, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_layers: int | None = None
|
||||||
|
try:
|
||||||
|
config_path = hf_hub_download(repo_id=hf_repo, filename="config.json", cache_dir=str(cache_dir))
|
||||||
|
config = json.loads(Path(config_path).read_text(encoding="utf-8"))
|
||||||
|
total_layers = layers_from_config_dict(config)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
selected = select_files_for_layers_from_index(
|
||||||
|
weight_map, shard_start, shard_end, total_layers=total_layers
|
||||||
|
)
|
||||||
|
return sorted(selected | METADATA_FILENAMES)
|
||||||
|
|
||||||
|
|
||||||
def download_shard(
|
def download_shard(
|
||||||
model: str,
|
model: str,
|
||||||
shard_start: int,
|
shard_start: int,
|
||||||
@@ -113,6 +448,7 @@ def download_shard(
|
|||||||
hf_repo: str | None = None,
|
hf_repo: str | None = None,
|
||||||
progress: bool = True,
|
progress: bool = True,
|
||||||
peers: list[dict] | None = None,
|
peers: list[dict] | None = None,
|
||||||
|
model_sources: list[dict] | None = None,
|
||||||
peer_timeout: float = _PEER_TIMEOUT_SECONDS,
|
peer_timeout: float = _PEER_TIMEOUT_SECONDS,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Ensure the shard is present in *cache_dir* and return its local path.
|
"""Ensure the shard is present in *cache_dir* and return its local path.
|
||||||
@@ -122,7 +458,16 @@ def download_shard(
|
|||||||
the test suite hermetic while the real download path is exercised by
|
the test suite hermetic while the real download path is exercised by
|
||||||
passing a non-stub *hf_repo*.
|
passing a non-stub *hf_repo*.
|
||||||
"""
|
"""
|
||||||
shard_dir = cache_dir / model / f"layers_{shard_start}-{shard_end}"
|
shard_dir = cache_dir / model
|
||||||
|
if progress:
|
||||||
|
print(f" Target location: {shard_dir}", flush=True)
|
||||||
|
|
||||||
|
for source in model_sources or []:
|
||||||
|
label = str(source.get("type") or "model-source")
|
||||||
|
if _source_files_cached(source, shard_dir):
|
||||||
|
if progress:
|
||||||
|
print(f" [{label}] requested files already cached at {shard_dir}", flush=True)
|
||||||
|
return shard_dir
|
||||||
|
|
||||||
for peer in peers or []:
|
for peer in peers or []:
|
||||||
if progress:
|
if progress:
|
||||||
@@ -157,18 +502,43 @@ def download_shard(
|
|||||||
print(f" [stub] shard already cached at {shard_dir}", flush=True)
|
print(f" [stub] shard already cached at {shard_dir}", flush=True)
|
||||||
return shard_dir
|
return shard_dir
|
||||||
|
|
||||||
from huggingface_hub import snapshot_download # type: ignore[import]
|
|
||||||
|
|
||||||
if progress:
|
if progress:
|
||||||
print(
|
print(
|
||||||
f" Downloading layers {shard_start}-{shard_end} from {hf_repo} ...",
|
f" Downloading layers {shard_start}-{shard_end} from {hf_repo} ...",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
print(" download source: HuggingFace", flush=True)
|
# Tracker (or peer) model sources are preferred outright — usually LAN-fast.
|
||||||
|
# HuggingFace is only the fallback when every advertised source fails.
|
||||||
local_dir = snapshot_download(
|
for source in model_sources or []:
|
||||||
repo_id=hf_repo,
|
label = str(source.get("type") or "model-source")
|
||||||
cache_dir=str(cache_dir),
|
if progress:
|
||||||
local_dir=str(shard_dir),
|
print(f" Downloading from {label} model source (HuggingFace is the fallback) ...", flush=True)
|
||||||
|
fetched = _download_model_source(
|
||||||
|
source,
|
||||||
|
shard_dir,
|
||||||
|
timeout=max(peer_timeout, _MODEL_SOURCE_TIMEOUT_SECONDS),
|
||||||
|
progress=progress,
|
||||||
|
label=label,
|
||||||
)
|
)
|
||||||
return Path(local_dir)
|
if fetched is not None:
|
||||||
|
if progress:
|
||||||
|
print(f" download source: {label}", flush=True)
|
||||||
|
return fetched
|
||||||
|
if model_sources and progress:
|
||||||
|
print(" All model sources failed — falling back to HuggingFace ...", flush=True)
|
||||||
|
|
||||||
|
allow_patterns = None
|
||||||
|
if model_sources:
|
||||||
|
allow_patterns = _allow_patterns_from_sources(model_sources)
|
||||||
|
if allow_patterns is None:
|
||||||
|
allow_patterns = _allow_patterns_from_remote_index(hf_repo, cache_dir, shard_start, shard_end)
|
||||||
|
if progress:
|
||||||
|
if allow_patterns:
|
||||||
|
print(" download source: HuggingFace (layer-filtered)", flush=True)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
" download source: HuggingFace (full snapshot — no SafeTensors index found)",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _download_huggingface_subset(hf_repo, cache_dir, shard_dir, allow_patterns)
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
Quantization = Literal["bfloat16", "int8", "nf4"]
|
Quantization = Literal["auto", "bfloat16", "int8", "nf4"]
|
||||||
|
|
||||||
|
|
||||||
class ModelBackendError(RuntimeError):
|
class ModelBackendError(RuntimeError):
|
||||||
@@ -22,6 +23,10 @@ class InsufficientVRAMError(ModelBackendError):
|
|||||||
"""Raised when a requested shard cannot fit in available CUDA memory."""
|
"""Raised when a requested shard cannot fit in available CUDA memory."""
|
||||||
|
|
||||||
|
|
||||||
|
class PartialModelLoadUnsupported(ModelBackendError):
|
||||||
|
"""Raised when a shard cannot be materialized from a local snapshot subset."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TensorPayload:
|
class TensorPayload:
|
||||||
body: bytes
|
body: bytes
|
||||||
@@ -31,14 +36,14 @@ class TensorPayload:
|
|||||||
|
|
||||||
|
|
||||||
def validate_quantization(value: str) -> Quantization:
|
def validate_quantization(value: str) -> Quantization:
|
||||||
if value not in {"bfloat16", "int8", "nf4"}:
|
if value not in {"auto", "bfloat16", "int8", "nf4"}:
|
||||||
raise ValueError("quantization must be one of: bfloat16, int8, nf4")
|
raise ValueError("quantization must be one of: auto, bfloat16, int8, nf4")
|
||||||
return value # type: ignore[return-value]
|
return value # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
def build_quantization_config(quantization: Quantization) -> Any | None:
|
def build_quantization_config(quantization: Quantization) -> Any | None:
|
||||||
"""Return a transformers BitsAndBytesConfig for quantized weights."""
|
"""Return a transformers BitsAndBytesConfig for quantized weights."""
|
||||||
if quantization == "bfloat16":
|
if quantization in {"auto", "bfloat16"}:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
import torch
|
import torch
|
||||||
@@ -65,7 +70,7 @@ class TorchModelShard:
|
|||||||
model_id: str,
|
model_id: str,
|
||||||
shard_start: int,
|
shard_start: int,
|
||||||
shard_end: int,
|
shard_end: int,
|
||||||
quantization: Quantization = "bfloat16",
|
quantization: Quantization = "auto",
|
||||||
cache_dir: Path | None = None,
|
cache_dir: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if shard_start < 0 or shard_end < 0 or shard_start > shard_end:
|
if shard_start < 0 or shard_end < 0 or shard_start > shard_end:
|
||||||
@@ -77,7 +82,7 @@ class TorchModelShard:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import torch
|
import torch
|
||||||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
|
||||||
except ModuleNotFoundError as exc:
|
except ModuleNotFoundError as exc:
|
||||||
raise MissingModelDependencyError(
|
raise MissingModelDependencyError(
|
||||||
"real model backend requires torch, transformers, safetensors, accelerate, and bitsandbytes"
|
"real model backend requires torch, transformers, safetensors, accelerate, and bitsandbytes"
|
||||||
@@ -85,17 +90,47 @@ class TorchModelShard:
|
|||||||
|
|
||||||
self.torch = torch
|
self.torch = torch
|
||||||
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
quant_config = build_quantization_config(quantization)
|
load_source = str(cache_dir) if cache_dir is not None and (cache_dir / "config.json").exists() else model_id
|
||||||
try:
|
quant_config, dtype, uses_quantized_weights = _model_load_plan(
|
||||||
self.model = AutoModelForCausalLM.from_pretrained(
|
AutoConfig,
|
||||||
model_id,
|
load_source,
|
||||||
quantization_config=quant_config,
|
quantization,
|
||||||
device_map="auto" if quant_config is not None else None,
|
torch,
|
||||||
dtype=torch.bfloat16,
|
None if load_source != model_id else cache_dir,
|
||||||
low_cpu_mem_usage=True,
|
|
||||||
cache_dir=str(cache_dir) if cache_dir is not None else None,
|
|
||||||
)
|
)
|
||||||
if quant_config is None:
|
try:
|
||||||
|
total_layers_hint = _total_layers_for_local_snapshot(AutoConfig, load_source)
|
||||||
|
if _should_partial_materialize_shard(
|
||||||
|
load_source,
|
||||||
|
shard_start,
|
||||||
|
shard_end,
|
||||||
|
total_layers_hint=total_layers_hint,
|
||||||
|
uses_quantized_weights=uses_quantized_weights,
|
||||||
|
):
|
||||||
|
self.model = _load_partial_model_from_snapshot(
|
||||||
|
AutoConfig,
|
||||||
|
AutoModelForCausalLM,
|
||||||
|
torch,
|
||||||
|
load_source,
|
||||||
|
shard_start,
|
||||||
|
shard_end,
|
||||||
|
dtype,
|
||||||
|
self.device,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
load_kwargs = {
|
||||||
|
"device_map": "auto" if uses_quantized_weights else None,
|
||||||
|
"dtype": dtype,
|
||||||
|
"low_cpu_mem_usage": True,
|
||||||
|
"cache_dir": str(cache_dir) if cache_dir is not None and load_source == model_id else None,
|
||||||
|
}
|
||||||
|
if quant_config is not None:
|
||||||
|
load_kwargs["quantization_config"] = quant_config
|
||||||
|
self.model = AutoModelForCausalLM.from_pretrained(
|
||||||
|
load_source,
|
||||||
|
**load_kwargs,
|
||||||
|
)
|
||||||
|
if not uses_quantized_weights:
|
||||||
self.model.to(self.device)
|
self.model.to(self.device)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if _looks_like_oom(exc):
|
if _looks_like_oom(exc):
|
||||||
@@ -107,8 +142,8 @@ class TorchModelShard:
|
|||||||
|
|
||||||
self.model.eval()
|
self.model.eval()
|
||||||
self.tokenizer = AutoTokenizer.from_pretrained(
|
self.tokenizer = AutoTokenizer.from_pretrained(
|
||||||
model_id,
|
load_source,
|
||||||
cache_dir=str(cache_dir) if cache_dir is not None else None,
|
cache_dir=str(cache_dir) if cache_dir is not None and load_source == model_id else None,
|
||||||
)
|
)
|
||||||
self.layers = _model_layers(self.model)
|
self.layers = _model_layers(self.model)
|
||||||
self.total_layers = len(self.layers)
|
self.total_layers = len(self.layers)
|
||||||
@@ -340,12 +375,200 @@ def load_torch_shard(
|
|||||||
model_id: str,
|
model_id: str,
|
||||||
shard_start: int,
|
shard_start: int,
|
||||||
shard_end: int,
|
shard_end: int,
|
||||||
quantization: Quantization = "bfloat16",
|
quantization: Quantization = "auto",
|
||||||
cache_dir: Path | None = None,
|
cache_dir: Path | None = None,
|
||||||
) -> TorchModelShard:
|
) -> TorchModelShard:
|
||||||
return TorchModelShard(model_id, shard_start, shard_end, quantization, cache_dir)
|
return TorchModelShard(model_id, shard_start, shard_end, quantization, cache_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _total_layers_for_local_snapshot(auto_config: Any, load_source: str) -> int | None:
|
||||||
|
snapshot_dir = Path(load_source)
|
||||||
|
if not (snapshot_dir / "config.json").exists():
|
||||||
|
return None
|
||||||
|
from .model_catalog import layers_from_config
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = auto_config.from_pretrained(str(snapshot_dir))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return layers_from_config(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_partial_materialize_shard(
|
||||||
|
load_source: str,
|
||||||
|
shard_start: int,
|
||||||
|
shard_end: int,
|
||||||
|
*,
|
||||||
|
total_layers_hint: int | None,
|
||||||
|
uses_quantized_weights: bool,
|
||||||
|
) -> bool:
|
||||||
|
if uses_quantized_weights:
|
||||||
|
return False
|
||||||
|
snapshot_dir = Path(load_source)
|
||||||
|
if not snapshot_dir.exists() or not (snapshot_dir / "config.json").exists():
|
||||||
|
return False
|
||||||
|
if not (snapshot_dir / "model.safetensors.index.json").exists():
|
||||||
|
return False
|
||||||
|
if total_layers_hint is None:
|
||||||
|
return False
|
||||||
|
return not (shard_start == 0 and shard_end >= total_layers_hint - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_partial_model_from_snapshot(
|
||||||
|
auto_config: Any,
|
||||||
|
auto_model_for_causal_lm: Any,
|
||||||
|
torch: Any,
|
||||||
|
load_source: str,
|
||||||
|
shard_start: int,
|
||||||
|
shard_end: int,
|
||||||
|
dtype: Any,
|
||||||
|
device: Any,
|
||||||
|
*,
|
||||||
|
init_empty_weights_fn: Any | None = None,
|
||||||
|
set_tensor_fn: Any | None = None,
|
||||||
|
safe_open_fn: Any | None = None,
|
||||||
|
) -> Any:
|
||||||
|
from .model_catalog import layers_from_config
|
||||||
|
from .safetensors_selection import (
|
||||||
|
INDEX_FILENAME,
|
||||||
|
select_tensor_names_for_layers_from_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
if init_empty_weights_fn is None:
|
||||||
|
from accelerate import init_empty_weights as init_empty_weights_fn
|
||||||
|
if set_tensor_fn is None:
|
||||||
|
from accelerate.utils import set_module_tensor_to_device as set_tensor_fn
|
||||||
|
if safe_open_fn is None:
|
||||||
|
from safetensors import safe_open as safe_open_fn
|
||||||
|
|
||||||
|
snapshot_dir = Path(load_source)
|
||||||
|
cfg = auto_config.from_pretrained(str(snapshot_dir))
|
||||||
|
total_layers = layers_from_config(cfg)
|
||||||
|
if total_layers is None:
|
||||||
|
raise PartialModelLoadUnsupported(
|
||||||
|
f"could not determine num_hidden_layers for local snapshot {snapshot_dir}"
|
||||||
|
)
|
||||||
|
if shard_end >= total_layers:
|
||||||
|
raise ValueError(
|
||||||
|
f"shard_end {shard_end} exceeds last layer index {total_layers - 1}"
|
||||||
|
)
|
||||||
|
|
||||||
|
index_path = snapshot_dir / INDEX_FILENAME
|
||||||
|
try:
|
||||||
|
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise PartialModelLoadUnsupported(
|
||||||
|
f"missing SafeTensors index for partial load: {index_path}"
|
||||||
|
) from exc
|
||||||
|
weight_map = index.get("weight_map")
|
||||||
|
if not isinstance(weight_map, dict):
|
||||||
|
raise PartialModelLoadUnsupported(f"{INDEX_FILENAME} must contain a weight_map object")
|
||||||
|
|
||||||
|
tensor_names = select_tensor_names_for_layers_from_index(
|
||||||
|
weight_map,
|
||||||
|
shard_start,
|
||||||
|
shard_end,
|
||||||
|
total_layers=total_layers,
|
||||||
|
)
|
||||||
|
if not tensor_names:
|
||||||
|
raise PartialModelLoadUnsupported(
|
||||||
|
f"no checkpoint tensors matched layers {shard_start}-{shard_end} in {snapshot_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
|
with init_empty_weights_fn():
|
||||||
|
model = auto_model_for_causal_lm.from_config(cfg, torch_dtype=dtype)
|
||||||
|
tie_weights = getattr(model, "tie_weights", None)
|
||||||
|
if callable(tie_weights):
|
||||||
|
tie_weights()
|
||||||
|
|
||||||
|
tensors_by_file: dict[str, list[str]] = {}
|
||||||
|
for tensor_name in sorted(tensor_names):
|
||||||
|
rel_file = weight_map.get(tensor_name)
|
||||||
|
if not isinstance(rel_file, str):
|
||||||
|
continue
|
||||||
|
tensors_by_file.setdefault(rel_file, []).append(tensor_name)
|
||||||
|
|
||||||
|
for rel_file, names in tensors_by_file.items():
|
||||||
|
checkpoint_file = snapshot_dir / rel_file
|
||||||
|
if not checkpoint_file.exists():
|
||||||
|
raise PartialModelLoadUnsupported(
|
||||||
|
f"checkpoint file advertised in {INDEX_FILENAME} is missing: {checkpoint_file}"
|
||||||
|
)
|
||||||
|
with safe_open_fn(str(checkpoint_file), framework="pt", device="cpu") as handle:
|
||||||
|
for tensor_name in names:
|
||||||
|
set_tensor_fn(
|
||||||
|
model,
|
||||||
|
tensor_name,
|
||||||
|
device,
|
||||||
|
value=handle.get_tensor(tensor_name),
|
||||||
|
dtype=dtype,
|
||||||
|
)
|
||||||
|
|
||||||
|
for module in _active_modules_for_shard(model, shard_start, shard_end):
|
||||||
|
if hasattr(module, "to"):
|
||||||
|
module.to(device)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def _model_load_plan(
|
||||||
|
auto_config: Any,
|
||||||
|
model_id: str,
|
||||||
|
quantization: Quantization,
|
||||||
|
torch: Any,
|
||||||
|
cache_dir: Path | None = None,
|
||||||
|
) -> tuple[Any | None, Any, bool]:
|
||||||
|
"""Return (explicit quant config, dtype, uses quantized weights)."""
|
||||||
|
if quantization != "auto":
|
||||||
|
quant_config = build_quantization_config(quantization)
|
||||||
|
return quant_config, torch.bfloat16, quant_config is not None
|
||||||
|
|
||||||
|
cfg = auto_config.from_pretrained(
|
||||||
|
model_id,
|
||||||
|
cache_dir=str(cache_dir) if cache_dir is not None else None,
|
||||||
|
)
|
||||||
|
if _native_quantization_config(cfg) is not None:
|
||||||
|
return None, _native_torch_dtype(cfg, torch), True
|
||||||
|
return None, _native_torch_dtype(cfg, torch), False
|
||||||
|
|
||||||
|
|
||||||
|
def _config_candidates(cfg: Any) -> list[Any]:
|
||||||
|
candidates = [cfg]
|
||||||
|
get_text_config = getattr(cfg, "get_text_config", None)
|
||||||
|
if callable(get_text_config):
|
||||||
|
try:
|
||||||
|
candidates.append(get_text_config())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
text_config = getattr(cfg, "text_config", None)
|
||||||
|
if text_config is not None:
|
||||||
|
candidates.append(text_config)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _native_quantization_config(cfg: Any) -> Any | None:
|
||||||
|
for candidate in _config_candidates(cfg):
|
||||||
|
quant_config = getattr(candidate, "quantization_config", None)
|
||||||
|
if quant_config:
|
||||||
|
return quant_config
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _native_torch_dtype(cfg: Any, torch: Any) -> Any:
|
||||||
|
for candidate in _config_candidates(cfg):
|
||||||
|
for attr in ("dtype", "torch_dtype"):
|
||||||
|
dtype = getattr(candidate, attr, None)
|
||||||
|
if dtype is None:
|
||||||
|
continue
|
||||||
|
if isinstance(dtype, str):
|
||||||
|
dtype_name = dtype.removeprefix("torch.")
|
||||||
|
dtype_value = getattr(torch, dtype_name, None)
|
||||||
|
if dtype_value is not None:
|
||||||
|
return dtype_value
|
||||||
|
else:
|
||||||
|
return dtype
|
||||||
|
return torch.bfloat16
|
||||||
|
|
||||||
|
|
||||||
def _model_layers(model: Any) -> Any:
|
def _model_layers(model: Any) -> Any:
|
||||||
if hasattr(model, "model") and hasattr(model.model, "layers"):
|
if hasattr(model, "model") and hasattr(model.model, "layers"):
|
||||||
return model.model.layers
|
return model.model.layers
|
||||||
@@ -372,6 +595,37 @@ def _position_embeddings(model: Any) -> Any | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rotary_embedding_module(model: Any) -> Any | None:
|
||||||
|
if hasattr(model, "model") and hasattr(model.model, "rotary_emb"):
|
||||||
|
return model.model.rotary_emb
|
||||||
|
if hasattr(model, "transformer") and hasattr(model.transformer, "rotary_emb"):
|
||||||
|
return model.transformer.rotary_emb
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _active_modules_for_shard(model: Any, shard_start: int, shard_end: int) -> list[Any]:
|
||||||
|
active: list[Any] = []
|
||||||
|
|
||||||
|
def add(module: Any | None) -> None:
|
||||||
|
if module is None:
|
||||||
|
return
|
||||||
|
if any(existing is module for existing in active):
|
||||||
|
return
|
||||||
|
active.append(module)
|
||||||
|
|
||||||
|
if shard_start == 0:
|
||||||
|
add(_embed_tokens(model))
|
||||||
|
add(_position_embeddings(model))
|
||||||
|
add(_rotary_embedding_module(model))
|
||||||
|
for layer in _model_layers(model)[shard_start:shard_end + 1]:
|
||||||
|
add(layer)
|
||||||
|
total_layers = len(_model_layers(model))
|
||||||
|
if shard_end >= total_layers - 1:
|
||||||
|
add(_final_norm(model))
|
||||||
|
add(getattr(model, "lm_head", None))
|
||||||
|
return active
|
||||||
|
|
||||||
|
|
||||||
def _final_norm(model: Any) -> Any | None:
|
def _final_norm(model: Any) -> Any | None:
|
||||||
if hasattr(model, "model") and hasattr(model.model, "norm"):
|
if hasattr(model, "model") and hasattr(model.model, "norm"):
|
||||||
return model.model.norm
|
return model.model.norm
|
||||||
@@ -415,11 +669,7 @@ def _rotary_position_embeddings(model: Any, hidden_states: Any, position_ids: An
|
|||||||
"""Return model-level rotary embeddings required by newer HF decoder layers."""
|
"""Return model-level rotary embeddings required by newer HF decoder layers."""
|
||||||
if position_ids is None:
|
if position_ids is None:
|
||||||
return None
|
return None
|
||||||
rotary = None
|
rotary = _rotary_embedding_module(model)
|
||||||
if hasattr(model, "model") and hasattr(model.model, "rotary_emb"):
|
|
||||||
rotary = model.model.rotary_emb
|
|
||||||
elif hasattr(model, "transformer") and hasattr(model.transformer, "rotary_emb"):
|
|
||||||
rotary = model.transformer.rotary_emb
|
|
||||||
if rotary is None:
|
if rotary is None:
|
||||||
return None
|
return None
|
||||||
return rotary(hidden_states, position_ids)
|
return rotary(hidden_states, position_ids)
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ def _load_model_metadata() -> dict[str, dict]:
|
|||||||
_MODEL_METADATA = _load_model_metadata()
|
_MODEL_METADATA = _load_model_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
def _local_model_path(hf_repo: str, cache_dir: Path | None) -> Path | None:
|
||||||
|
if cache_dir is None:
|
||||||
|
return None
|
||||||
|
if (cache_dir / "config.json").exists():
|
||||||
|
return cache_dir
|
||||||
|
candidate = cache_dir / hf_repo.split("/")[-1]
|
||||||
|
if (candidate / "config.json").exists():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
CURATED_MODELS: list[ModelPreset] = [
|
CURATED_MODELS: list[ModelPreset] = [
|
||||||
ModelPreset(
|
ModelPreset(
|
||||||
name="Qwen2.5-0.5B-Instruct",
|
name="Qwen2.5-0.5B-Instruct",
|
||||||
@@ -159,6 +170,30 @@ CURATED_MODELS: list[ModelPreset] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def layers_from_config(cfg) -> int | None:
|
||||||
|
"""Extract the transformer layer count from a HuggingFace config object.
|
||||||
|
|
||||||
|
Composite configs (vision-language, some MoE) nest the decoder settings in
|
||||||
|
``text_config`` — e.g. Qwen3.5-MoE has no top-level ``num_hidden_layers``.
|
||||||
|
"""
|
||||||
|
candidates = [cfg]
|
||||||
|
get_text_config = getattr(cfg, "get_text_config", None)
|
||||||
|
if callable(get_text_config):
|
||||||
|
try:
|
||||||
|
candidates.append(get_text_config())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
nested = getattr(cfg, "text_config", None)
|
||||||
|
if nested is not None:
|
||||||
|
candidates.append(nested)
|
||||||
|
for candidate in candidates:
|
||||||
|
for attr in ("num_hidden_layers", "num_layers", "n_layer", "n_layers"):
|
||||||
|
value = getattr(candidate, attr, None)
|
||||||
|
if value is not None:
|
||||||
|
return int(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def detect_num_layers(hf_repo: str) -> int | None:
|
def detect_num_layers(hf_repo: str) -> int | None:
|
||||||
"""Return num_hidden_layers from HuggingFace config.json (downloads ~1 KB only)."""
|
"""Return num_hidden_layers from HuggingFace config.json (downloads ~1 KB only)."""
|
||||||
# Check curated list first (no network call)
|
# Check curated list first (no network call)
|
||||||
@@ -168,7 +203,7 @@ def detect_num_layers(hf_repo: str) -> int | None:
|
|||||||
try:
|
try:
|
||||||
from transformers import AutoConfig # type: ignore[import]
|
from transformers import AutoConfig # type: ignore[import]
|
||||||
cfg = AutoConfig.from_pretrained(hf_repo)
|
cfg = AutoConfig.from_pretrained(hf_repo)
|
||||||
return int(cfg.num_hidden_layers)
|
return layers_from_config(cfg)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -191,10 +226,14 @@ def model_metadata_for(
|
|||||||
try:
|
try:
|
||||||
from transformers import AutoConfig # type: ignore[import]
|
from transformers import AutoConfig # type: ignore[import]
|
||||||
|
|
||||||
|
local_model = _local_model_path(hf_repo, cache_dir)
|
||||||
|
load_source = str(local_model) if local_model is not None else hf_repo
|
||||||
cfg = AutoConfig.from_pretrained(
|
cfg = AutoConfig.from_pretrained(
|
||||||
hf_repo,
|
load_source,
|
||||||
cache_dir=str(cache_dir) if cache_dir is not None else None,
|
cache_dir=str(cache_dir) if cache_dir is not None and local_model is None else None,
|
||||||
)
|
)
|
||||||
|
# Composite configs (VLM/MoE) nest decoder fields in text_config.
|
||||||
|
text_cfg = getattr(cfg, "text_config", None) or cfg
|
||||||
for attr, key in (
|
for attr, key in (
|
||||||
("model_type", "architecture"),
|
("model_type", "architecture"),
|
||||||
("num_hidden_layers", "num_layers"),
|
("num_hidden_layers", "num_layers"),
|
||||||
@@ -204,8 +243,14 @@ def model_metadata_for(
|
|||||||
("max_position_embeddings", "context_length"),
|
("max_position_embeddings", "context_length"),
|
||||||
):
|
):
|
||||||
value = getattr(cfg, attr, None)
|
value = getattr(cfg, attr, None)
|
||||||
|
if value is None:
|
||||||
|
value = getattr(text_cfg, attr, None)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
metadata[key] = value
|
metadata[key] = value
|
||||||
|
if "num_layers" not in metadata:
|
||||||
|
layers = layers_from_config(cfg)
|
||||||
|
if layers is not None:
|
||||||
|
metadata["num_layers"] = layers
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return metadata
|
return metadata
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
"vision_encoder_parameters": "400M",
|
"vision_encoder_parameters": "400M",
|
||||||
"license": "modified-mit",
|
"license": "modified-mit",
|
||||||
"native_quantization": "int4",
|
"native_quantization": "int4",
|
||||||
|
"canonical_audit_dtype": "bfloat16",
|
||||||
|
"canonical_audit_quantization": "bfloat16",
|
||||||
"download_size_gb": 595,
|
"download_size_gb": 595,
|
||||||
"recommended_short_name": "kimi-k2.7",
|
"recommended_short_name": "kimi-k2.7",
|
||||||
"recommended_engines": [
|
"recommended_engines": [
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_MAX_CONCURRENCY = 8
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RelayBridgeInfo:
|
class RelayBridgeInfo:
|
||||||
@@ -32,8 +36,23 @@ def _make_envelope(topic: str, payload: dict, peer_id: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _max_concurrency_from_env() -> int:
|
||||||
|
try:
|
||||||
|
value = int(os.environ.get("MESHNET_RELAY_CONCURRENCY", DEFAULT_MAX_CONCURRENCY))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return DEFAULT_MAX_CONCURRENCY
|
||||||
|
return max(1, value)
|
||||||
|
|
||||||
|
|
||||||
class RelayHttpBridge:
|
class RelayHttpBridge:
|
||||||
"""Connect outbound to a relay and proxy relay HTTP requests to localhost."""
|
"""Connect outbound to a relay and proxy relay HTTP requests to localhost.
|
||||||
|
|
||||||
|
Requests are dispatched on a bounded worker pool (US-037) so a node that is
|
||||||
|
the head of one route can still serve per-token ``/forward`` hops of another.
|
||||||
|
Streaming responses (``text/event-stream``) are forwarded as multiple chunk
|
||||||
|
frames sharing one ``request_id`` (US-036); everything else stays a single
|
||||||
|
frame for backward compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -42,15 +61,20 @@ class RelayHttpBridge:
|
|||||||
local_base_url: str,
|
local_base_url: str,
|
||||||
advertised_addr: str,
|
advertised_addr: str,
|
||||||
reconnect_interval: float = 3.0,
|
reconnect_interval: float = 3.0,
|
||||||
|
max_concurrency: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.relay_url = relay_url.rstrip("/")
|
self.relay_url = relay_url.rstrip("/")
|
||||||
self.peer_id = peer_id
|
self.peer_id = peer_id
|
||||||
self.local_base_url = local_base_url.rstrip("/")
|
self.local_base_url = local_base_url.rstrip("/")
|
||||||
self.advertised_addr = advertised_addr
|
self.advertised_addr = advertised_addr
|
||||||
self.reconnect_interval = reconnect_interval
|
self.reconnect_interval = reconnect_interval
|
||||||
|
self.max_concurrency = max(1, max_concurrency) if max_concurrency else _max_concurrency_from_env()
|
||||||
self._running = False
|
self._running = False
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._connected = threading.Event()
|
self._connected = threading.Event()
|
||||||
|
self._executor: ThreadPoolExecutor | None = None
|
||||||
|
self._send_lock = threading.Lock()
|
||||||
|
self._ws = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def relay_addr(self) -> str:
|
def relay_addr(self) -> str:
|
||||||
@@ -61,6 +85,9 @@ class RelayHttpBridge:
|
|||||||
|
|
||||||
def start(self) -> RelayBridgeInfo:
|
def start(self) -> RelayBridgeInfo:
|
||||||
self._running = True
|
self._running = True
|
||||||
|
self._executor = ThreadPoolExecutor(
|
||||||
|
max_workers=self.max_concurrency, thread_name_prefix="relay-http-worker"
|
||||||
|
)
|
||||||
self._thread = threading.Thread(target=self._run, daemon=True, name="relay-http-bridge")
|
self._thread = threading.Thread(target=self._run, daemon=True, name="relay-http-bridge")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
return RelayBridgeInfo(peer_id=self.peer_id, relay_addr=self.relay_addr)
|
return RelayBridgeInfo(peer_id=self.peer_id, relay_addr=self.relay_addr)
|
||||||
@@ -72,6 +99,8 @@ class RelayHttpBridge:
|
|||||||
self._running = False
|
self._running = False
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=3.0)
|
self._thread.join(timeout=3.0)
|
||||||
|
if self._executor is not None:
|
||||||
|
self._executor.shutdown(wait=False)
|
||||||
|
|
||||||
def _run(self) -> None:
|
def _run(self) -> None:
|
||||||
import websockets.sync.client as wsc # type: ignore[import]
|
import websockets.sync.client as wsc # type: ignore[import]
|
||||||
@@ -79,6 +108,7 @@ class RelayHttpBridge:
|
|||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
with wsc.connect(self.relay_url, open_timeout=5) as ws:
|
with wsc.connect(self.relay_url, open_timeout=5) as ws:
|
||||||
|
self._ws = ws
|
||||||
self._connected.set()
|
self._connected.set()
|
||||||
ws.send(json.dumps(_make_envelope(
|
ws.send(json.dumps(_make_envelope(
|
||||||
"peer-register",
|
"peer-register",
|
||||||
@@ -99,19 +129,36 @@ class RelayHttpBridge:
|
|||||||
payload = envelope.get("payload", {})
|
payload = envelope.get("payload", {})
|
||||||
if payload.get("target_peer") not in {None, self.peer_id}:
|
if payload.get("target_peer") not in {None, self.peer_id}:
|
||||||
continue
|
continue
|
||||||
response = self._handle_request(payload)
|
if self._executor is None:
|
||||||
ws.send(json.dumps(_make_envelope(
|
break
|
||||||
"relay-http-response",
|
self._executor.submit(self._process_request, payload)
|
||||||
response,
|
|
||||||
self.peer_id,
|
|
||||||
)))
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._connected.clear()
|
self._connected.clear()
|
||||||
|
self._ws = None
|
||||||
if self._running:
|
if self._running:
|
||||||
log.debug("relay bridge disconnected: %s", exc)
|
log.debug("relay bridge disconnected: %s", exc)
|
||||||
time.sleep(self.reconnect_interval)
|
time.sleep(self.reconnect_interval)
|
||||||
|
self._ws = None
|
||||||
|
|
||||||
def _handle_request(self, payload: dict) -> dict:
|
def _send_response_frame(self, payload: dict) -> bool:
|
||||||
|
"""Send one relay-http-response frame; False if the socket is gone.
|
||||||
|
|
||||||
|
The lock is held per frame so concurrent workers interleave whole
|
||||||
|
frames on the shared WebSocket, never torn ones.
|
||||||
|
"""
|
||||||
|
ws = self._ws
|
||||||
|
if ws is None:
|
||||||
|
return False
|
||||||
|
message = json.dumps(_make_envelope("relay-http-response", payload, self.peer_id))
|
||||||
|
try:
|
||||||
|
with self._send_lock:
|
||||||
|
ws.send(message)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("relay bridge send failed (request orphaned): %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _process_request(self, payload: dict) -> None:
|
||||||
request_id = str(payload.get("request_id") or "")
|
request_id = str(payload.get("request_id") or "")
|
||||||
method = str(payload.get("method") or "POST").upper()
|
method = str(payload.get("method") or "POST").upper()
|
||||||
path = str(payload.get("path") or "/")
|
path = str(payload.get("path") or "/")
|
||||||
@@ -130,10 +177,14 @@ class RelayHttpBridge:
|
|||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=300.0) as resp:
|
with urllib.request.urlopen(req, timeout=300.0) as resp:
|
||||||
resp_bytes = resp.read()
|
|
||||||
resp_headers = dict(resp.headers)
|
resp_headers = dict(resp.headers)
|
||||||
|
content_type = resp.headers.get("Content-Type", "")
|
||||||
|
if "text/event-stream" in content_type:
|
||||||
|
self._stream_response(request_id, resp, resp_headers)
|
||||||
|
return
|
||||||
|
resp_bytes = resp.read()
|
||||||
# Forward all X-Meshnet-* headers so the caller can reconstruct the activation.
|
# Forward all X-Meshnet-* headers so the caller can reconstruct the activation.
|
||||||
is_binary = "octet-stream" in resp.headers.get("Content-Type", "")
|
is_binary = "octet-stream" in content_type
|
||||||
result: dict = {
|
result: dict = {
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"status": resp.status,
|
"status": resp.status,
|
||||||
@@ -143,21 +194,66 @@ class RelayHttpBridge:
|
|||||||
result["body_base64"] = base64.b64encode(resp_bytes).decode()
|
result["body_base64"] = base64.b64encode(resp_bytes).decode()
|
||||||
else:
|
else:
|
||||||
result["body"] = resp_bytes.decode(errors="replace")
|
result["body"] = resp_bytes.decode(errors="replace")
|
||||||
return result
|
self._send_response_frame(result)
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
return {
|
self._send_response_frame({
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"status": exc.code,
|
"status": exc.code,
|
||||||
"headers": {"Content-Type": exc.headers.get("Content-Type", "application/json")},
|
"headers": {"Content-Type": exc.headers.get("Content-Type", "application/json")},
|
||||||
"body": exc.read().decode(errors="replace"),
|
"body": exc.read().decode(errors="replace"),
|
||||||
}
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {
|
self._send_response_frame({
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"status": 503,
|
"status": 503,
|
||||||
"headers": {"Content-Type": "application/json"},
|
"headers": {"Content-Type": "application/json"},
|
||||||
"body": json.dumps({"error": f"relay bridge local request failed: {exc}"}),
|
"body": json.dumps({"error": f"relay bridge local request failed: {exc}"}),
|
||||||
}
|
})
|
||||||
|
|
||||||
|
def _stream_response(self, request_id: str, resp, resp_headers: dict) -> None:
|
||||||
|
"""Forward an SSE response as chunk frames, one per complete SSE event.
|
||||||
|
|
||||||
|
Frame order: header frame (status + headers), chunk frames, done frame.
|
||||||
|
A receiver that sees no ``stream`` key treats the frame as a complete
|
||||||
|
legacy response, so non-streaming peers are unaffected.
|
||||||
|
"""
|
||||||
|
sent = self._send_response_frame({
|
||||||
|
"request_id": request_id,
|
||||||
|
"status": resp.status,
|
||||||
|
"headers": resp_headers,
|
||||||
|
"stream": True,
|
||||||
|
"done": False,
|
||||||
|
})
|
||||||
|
if not sent:
|
||||||
|
return
|
||||||
|
event_lines: list[str] = []
|
||||||
|
for raw_line in resp:
|
||||||
|
line = raw_line.decode(errors="replace")
|
||||||
|
event_lines.append(line)
|
||||||
|
if line.strip():
|
||||||
|
continue
|
||||||
|
# Blank line terminates one SSE event — flush it as a frame.
|
||||||
|
if not self._send_response_frame({
|
||||||
|
"request_id": request_id,
|
||||||
|
"stream": True,
|
||||||
|
"chunk": "".join(event_lines),
|
||||||
|
"done": False,
|
||||||
|
}):
|
||||||
|
return
|
||||||
|
event_lines = []
|
||||||
|
if event_lines:
|
||||||
|
if not self._send_response_frame({
|
||||||
|
"request_id": request_id,
|
||||||
|
"stream": True,
|
||||||
|
"chunk": "".join(event_lines),
|
||||||
|
"done": False,
|
||||||
|
}):
|
||||||
|
return
|
||||||
|
self._send_response_frame({
|
||||||
|
"request_id": request_id,
|
||||||
|
"stream": True,
|
||||||
|
"done": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def peer_id_from_wallet(wallet_address: str) -> str:
|
def peer_id_from_wallet(wallet_address: str) -> str:
|
||||||
|
|||||||
210
packages/node/meshnet_node/safetensors_selection.py
Normal file
210
packages/node/meshnet_node/safetensors_selection.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""Layer-aware SafeTensors snapshot file selection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
INDEX_FILENAME = "model.safetensors.index.json"
|
||||||
|
|
||||||
|
_LAYER_RE = re.compile(
|
||||||
|
r"(?:^|\.)"
|
||||||
|
r"(?:model\.layers|layers|h|blocks|decoder\.layers|encoder\.layers)"
|
||||||
|
r"\.(\d+)(?:\.|$)"
|
||||||
|
)
|
||||||
|
|
||||||
|
METADATA_FILENAMES = {
|
||||||
|
INDEX_FILENAME,
|
||||||
|
"config.json",
|
||||||
|
"generation_config.json",
|
||||||
|
"preprocessor_config.json",
|
||||||
|
"special_tokens_map.json",
|
||||||
|
"tokenizer.json",
|
||||||
|
"tokenizer.model",
|
||||||
|
"tokenizer_config.json",
|
||||||
|
"vocab.json",
|
||||||
|
"merges.txt",
|
||||||
|
"added_tokens.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
_METADATA_PREFIXES = ("config.", "tokenizer.", "tokenizer_", "vocab.")
|
||||||
|
|
||||||
|
_HEAD_MARKERS = (
|
||||||
|
"embed",
|
||||||
|
"embedding",
|
||||||
|
"embed_tokens",
|
||||||
|
"wte",
|
||||||
|
"wpe",
|
||||||
|
)
|
||||||
|
|
||||||
|
_TAIL_EXACT = {
|
||||||
|
"lm_head.weight",
|
||||||
|
"lm_head.bias",
|
||||||
|
"model.norm.weight",
|
||||||
|
"model.norm.bias",
|
||||||
|
"transformer.ln_f.weight",
|
||||||
|
"transformer.ln_f.bias",
|
||||||
|
"decoder.final_layer_norm.weight",
|
||||||
|
"decoder.final_layer_norm.bias",
|
||||||
|
}
|
||||||
|
|
||||||
|
_TAIL_MARKERS = (
|
||||||
|
".lm_head.",
|
||||||
|
".norm.",
|
||||||
|
".ln_f.",
|
||||||
|
".final_layer_norm.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def select_safetensors_files_for_layers(
|
||||||
|
model_dir: str | Path,
|
||||||
|
start_layer: int,
|
||||||
|
end_layer: int,
|
||||||
|
*,
|
||||||
|
total_layers: int | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return relative snapshot files needed for an inclusive layer range.
|
||||||
|
|
||||||
|
The returned list always includes root-level config/tokenizer metadata and
|
||||||
|
the SafeTensors index. Weight shard files are included only when at least one
|
||||||
|
tensor in the index belongs to the assigned layer range, or when the tensor
|
||||||
|
is needed by the head/tail shard.
|
||||||
|
"""
|
||||||
|
if start_layer < 0:
|
||||||
|
raise ValueError("start_layer must be non-negative")
|
||||||
|
if end_layer < start_layer:
|
||||||
|
raise ValueError("end_layer must be greater than or equal to start_layer")
|
||||||
|
|
||||||
|
root = Path(model_dir)
|
||||||
|
index_path = root / INDEX_FILENAME
|
||||||
|
try:
|
||||||
|
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise FileNotFoundError(f"missing SafeTensors index: {index_path}") from exc
|
||||||
|
|
||||||
|
weight_map = index.get("weight_map")
|
||||||
|
if not isinstance(weight_map, dict):
|
||||||
|
raise ValueError(f"{INDEX_FILENAME} must contain a weight_map object")
|
||||||
|
|
||||||
|
inferred_total_layers = total_layers if total_layers is not None else _read_total_layers(root)
|
||||||
|
selected = _metadata_files(root)
|
||||||
|
selected |= select_files_for_layers_from_index(
|
||||||
|
weight_map, start_layer, end_layer, total_layers=inferred_total_layers
|
||||||
|
)
|
||||||
|
return sorted(selected)
|
||||||
|
|
||||||
|
|
||||||
|
def select_files_for_layers_from_index(
|
||||||
|
weight_map: dict[str, str],
|
||||||
|
start_layer: int,
|
||||||
|
end_layer: int,
|
||||||
|
*,
|
||||||
|
total_layers: int | None = None,
|
||||||
|
) -> set[str]:
|
||||||
|
"""Pure variant of the weight-file selection: takes an already-parsed
|
||||||
|
``weight_map`` (no local snapshot directory needed), so callers that only
|
||||||
|
have the index fetched over the network — not a full local snapshot — can
|
||||||
|
still compute which shard files they need. Combine the result with
|
||||||
|
``METADATA_FILENAMES`` for a complete download pattern set.
|
||||||
|
"""
|
||||||
|
selected: set[str] = set()
|
||||||
|
for tensor_name, rel_file in weight_map.items():
|
||||||
|
if not isinstance(tensor_name, str) or not isinstance(rel_file, str):
|
||||||
|
continue
|
||||||
|
if _tensor_belongs_to_range(tensor_name, start_layer, end_layer, total_layers):
|
||||||
|
selected.add(_normalise_relative_file(rel_file))
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def select_tensor_names_for_layers_from_index(
|
||||||
|
weight_map: dict[str, str],
|
||||||
|
start_layer: int,
|
||||||
|
end_layer: int,
|
||||||
|
*,
|
||||||
|
total_layers: int | None = None,
|
||||||
|
) -> set[str]:
|
||||||
|
"""Pure variant that returns checkpoint tensor names instead of file paths."""
|
||||||
|
selected: set[str] = set()
|
||||||
|
for tensor_name, rel_file in weight_map.items():
|
||||||
|
if not isinstance(tensor_name, str) or not isinstance(rel_file, str):
|
||||||
|
continue
|
||||||
|
if _tensor_belongs_to_range(tensor_name, start_layer, end_layer, total_layers):
|
||||||
|
selected.add(tensor_name)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _tensor_belongs_to_range(
|
||||||
|
tensor_name: str,
|
||||||
|
start_layer: int,
|
||||||
|
end_layer: int,
|
||||||
|
total_layers: int | None,
|
||||||
|
) -> bool:
|
||||||
|
layer = _layer_index(tensor_name)
|
||||||
|
if layer is not None:
|
||||||
|
return start_layer <= layer <= end_layer
|
||||||
|
|
||||||
|
if start_layer == 0 and _is_head_tensor(tensor_name):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if total_layers is not None and end_layer >= total_layers - 1 and _is_tail_tensor(tensor_name):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _layer_index(tensor_name: str) -> int | None:
|
||||||
|
match = _LAYER_RE.search(tensor_name)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_head_tensor(tensor_name: str) -> bool:
|
||||||
|
lowered = tensor_name.lower()
|
||||||
|
return any(marker in lowered for marker in _HEAD_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tail_tensor(tensor_name: str) -> bool:
|
||||||
|
lowered = tensor_name.lower()
|
||||||
|
return lowered in _TAIL_EXACT or any(marker in lowered for marker in _TAIL_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_files(root: Path) -> set[str]:
|
||||||
|
files = {INDEX_FILENAME}
|
||||||
|
for path in root.iterdir():
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
name = path.name
|
||||||
|
if name in METADATA_FILENAMES or name.startswith(_METADATA_PREFIXES):
|
||||||
|
files.add(name)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _read_total_layers(root: Path) -> int | None:
|
||||||
|
config_path = root / "config.json"
|
||||||
|
if not config_path.exists():
|
||||||
|
return None
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
return layers_from_config_dict(config)
|
||||||
|
|
||||||
|
|
||||||
|
def layers_from_config_dict(config: dict[str, Any]) -> int | None:
|
||||||
|
for key in ("num_hidden_layers", "num_layers", "n_layer", "n_layers"):
|
||||||
|
value = config.get(key)
|
||||||
|
if isinstance(value, int) and value > 0:
|
||||||
|
return value
|
||||||
|
|
||||||
|
text_config = config.get("text_config")
|
||||||
|
if isinstance(text_config, dict):
|
||||||
|
return layers_from_config_dict(text_config)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_relative_file(rel_file: str) -> str:
|
||||||
|
path = Path(rel_file)
|
||||||
|
if path.is_absolute() or ".." in path.parts:
|
||||||
|
raise ValueError(f"unsafe relative file in {INDEX_FILENAME}: {rel_file}")
|
||||||
|
return path.as_posix()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user