openvibers/openvibe.sdk Commits

Placement v1: quality, rules, learning and a line (0.76.0) (#104)

OP OpenVibers committed 13 days ago · 9963b3ed2a · 14 files, +1086 −27

* Placement v1: quality, rules, learning and a line (0.76.0)

* Scrub the step kind in the no-bar placement line

* Placement review: global counts, honest line, one escalation try

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bd437f8..64536a4 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,42 @@
# Changelog
++## 0.76.0 (2026-10-30)
++
++- **`openvibe-sdk/placement`: placement v1 — quality, the person's rules, learning and a reason (agent plan A1).**
++ - `place(requirements, offers, ctx)` is `plan()` with the plan's road: the person's rules and ownership, a quality
++ floor (default 0.5) with a fallback, the success estimate, cost per success, escalation and one plain sentence
++ saying why. It returns `{ result, line, ranked, rejected, fallback }`; `result` is exactly what `plan()` returns
++ for the offers left in, validated as today.
++ - `learn(outcomes, {person, key, kind, ok, at})` and `successOf(outcomes, {person, key, kind, prior, now})` keep
++ and read plain counts with a 14-day half-life, a Beta mean, Bench priors, the global weight `min(1, 20/nG)` over
++ **everyone else's** counts (the person's rows are not counted twice) and the dotted-kind fallback.
++ `escalate(previous, failure, requirements, offers, ctx)` handles quality, busy, offline, refused, budget and
++ timeout, and asks the person after two escalations. It learns the failed try exactly once and returns the new
++ counts as `outcomes`; the caller persists that set and must not record the same failure again.
++ `optionKey(offer)` and `lineFor(facts)` are exported too.
++ - On the person's own free machine the line claims only what the step asked for: `harness:tools` (the harness's
++ own tools, including the signed-in CLI) says "your sign-in", and `harness:host-access` (the person's machine and
++ its files) says "your files". With neither it says "your own machine".
++ - `plan()` takes an optional `success: { [offer_id]: p }`: when present the cost part of the score is
++ `estimated_cost_usd / p`, while `estimated_cost_usd` in the result stays the raw cost. When absent, scoring is
++ unchanged.
++ - **Behaviour fix: `user-owned` is never a default.** When `requirements.trust` is absent, `plan()` now uses
++ `DEFAULT_TRUST` (`first-party`, `partner`, `community`, `external`). The contract
++ (`platform.workload-requirements@1`) says exactly that: "when absent, first-party, partner, community and
++ external. user-owned is never a default: a user-owned offer is eligible only when this list names it". A
++ stranger's machine must never be a default. `TRUST_ORDER` keeps all five classes as the preference order;
++ `DEFAULT_TRUST` is the four-class default. `place()` also requires a user-owned offer's `owner` to be the person
++ of the step.
++ - **Streaming machines.** A step that asks for a GPU or 2 or more cores excludes an offer whose
++ `health.streaming` is `true`, with the reason "it is streaming". The field is not in
++ `platform.resource-offer@1` yet (Node reports `screen.state` in its own status, Bot's offer does not carry it),
++ so this release fixes the name the Node can start sending; a top-level `offer.streaming` is tolerated too.
++ - README and [docs/placement.md](docs/placement.md): the API, the rules, the estimate, escalation and the line
++ templates.
++
++All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
++release may change an API and says so here.
++
## 0.75.0 (2026-10-30)
- **`openvibe-sdk/redact`: one secret scrubber for the Node and AI (Memory M2).**
ddiff --git a/README.md b/README.md
iindex 9ae8db6..e1da95d 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.75.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.76.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.75.0 is tagged:
++After v0.76.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@@ -189,6 +189,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/resources` | server | `parseResourceName`/`resourceName`/`resourceNameOf` (OVRN, via the pinned `contracts.resources`) and `RESOURCE_KINDS` (only the chosen `med`/`wch` prefixes); `createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs})`: `list({project, kind, owner, limit})` -> `{resources, stale}` (`owner`: a subject id, each authority answering only what it owns), fanning out over the authorities' `GET /api/v1/resources`, following each opaque `next_cursor` and merging `common.resource-list-result@1` pages (an authority that fails — or whose cursor chain repeats, outruns `maxPages`, or outruns `maxMs` — is reported in `stale`, never fatal), and `iterate`; `createResourceClient({origin, token})`: `control(request)` POSTs a `common.resource-control-request@1` to `/api/v1/resources/control` — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that `idempotencyKey` — and returns the `common.resource-control-result@1` (`done`/`pending`/`refused`/`failed`, a refused/failed one returned even with a non-retryable non-2xx) |
| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
++| `openvibe-sdk/placement` | server | `plan(requirements, offers, {rateCards, states, now, current, minGain, weights, success})` → `platform.placement-result@1`; `place(requirements, offers, {…plan, person, step, floor, priors, outcomes, rules, names})` → `{result, line, ranked, rejected, fallback}`: the person's rules and ownership, the quality bar, cost per success and one sentence saying why; `optionKey`, `learn`, `successOf`, `escalate`, `lineFor`; `TRUST_CLASSES`, `TRUST_ORDER`, `DEFAULT_TRUST` ([details](docs/placement.md)) |
| `openvibe-sdk/play` | server | `createPlayClient(client)`: OpenVibe.Play's achievements and leaderboards for any game — `titles.define(title, {name, url?, summary?, achievements, boards})`, `scores.post(title, {board, ref, entries})`, `achievements.unlock(title, {player, achievements})`, `players.adopt(title, guest, person)`, `players.achievements(title, player)`; public `titles.list/get` and `boards.get(title, board, {period, page})` need no token |
| `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync <folder> --to <drive/path>`, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) |
| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
ddiff --git a/STATUS.json b/STATUS.json
iindex 17ca59d..fb9b9a7 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.75.0",
++ "version": "0.76.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@@ -32,7 +32,8 @@
"the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js",
"the telemetry sink (openvibe-sdk/telemetry): telemetrySchema() DDL, createPgTelemetrySink() and pruneTelemetry(), a per-service PostgreSQL table every service stores platform.telemetry-sample@1 into",
"account-data (openvibe-sdk/account-data, ADR-033): telemetry_samples covered once by the SDK (exported as telemetry.json, anonymized on deletion), and uncovered() listing every table with a person column that no table map, task-keyed list or reasoned exemption covers",
-- "resources (openvibe-sdk/resources): the ADR-048 resource index fan-out and the idempotent control client"
++ "resources (openvibe-sdk/resources): the ADR-048 resource index fan-out and the idempotent control client",
++ "placement v1 (openvibe-sdk/placement): place() with the person's rules and ownership, the quality bar, cost per success, learn/successOf, escalate and the one-line reason; plan() takes the optional success map and never defaults to user-owned"
],
"updated": "2026-10-30"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex d6a341f..3961ad2 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.75.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.76.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, bench, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -46,7 +46,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.75.0';
++const SDK_VERSION = '0.76.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex e439bc2..7852736 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -590,19 +590,26 @@
"createOpenReClient": "function"
},
"openvibe-sdk/placement": {
++ "DEFAULT_TRUST": "array",
"TRUST_CLASSES": "array",
"TRUST_ORDER": "array",
"canonical": "function",
"createPlanHolder": "function",
++ "escalate": "function",
"excluded": "function",
"forecast": "function",
++ "learn": "function",
++ "lineFor": "function",
"marginalCost": "function",
++ "optionKey": "function",
"ownedLoadCost": "function",
"pickTwo": "function",
++ "place": "function",
"plan": "function",
"priceOf": "function",
"rendezvous": "function",
"signPlan": "function",
++ "successOf": "function",
"verifyPlan": "function"
},
"openvibe-sdk/play": {
ddiff --git a/docs/placement.md b/docs/placement.md
nnew file mode 100644
iindex 0000000..9236c3d
---- /dev/null
++++ b/docs/placement.md
@@@ -0,0 +1,159 @@
++# openvibe-sdk/placement
++
++Where a workload runs (roadmap WS-Z9, decision 43). `plan()` is the contract-shaped planner: hard constraints
++first, then cost and latency, with marginal cost after free allowances, owned-capacity load, hysteresis and signed
++route plans. It is pure: the caller passes the offers, rate cards, provider states and `now`.
++
++This release adds the placement brain's A1 layer: **`place()`** wraps `plan()` with a quality bar, a success
++estimate, the person's own rules, learning from outcomes, escalation and one plain sentence saying why. Everything
++stays pure (no I/O, no clock, no randomness); `now` always comes in.
++
++```js
++const { place, learn, escalate } = require('openvibe-sdk/placement');
++
++const out = place(
++ { kind: 'harness.edit', mobility: 'job', latency_class: 'interactive', objective: 'balanced',
++ capabilities: ['task:edit'], trust: ['user-owned', 'first-party'] },
++ offers, // platform.resource-offer@1
++ {
++ now: Date.parse('2026-10-30T12:00:00Z'),
++ rateCards, states, // everything plan() takes
++ person: 'usr_…',
++ step: { kind: 'code.fix' }, // for quality and learning (free text, dotted)
++ floor: 0.6, // the quality bar for this kind; default 0.5
++ priors: { 'app:codes/claude-code@opus': 0.82 },
++ outcomes, // the learned counts (below)
++ rules: { noCloud: true, onlyFree: false, region: 'eu', prefer: 'cheapest' },
++ names: { 'node:desk': 'your desktop' },
++ },
++);
++
++out.result; // platform.placement-result@1 — exactly what plan() returns for the offers left in; validate it
++out.line; // "On your desktop: it has your Claude sign-in and your files, and it's free."
++out.ranked; // [{ id, success, cost_usd, cost_per_success_usd }], best first by the planner's score
++out.rejected; // [{ id, why }] — every exclusion, in plain words
++out.fallback; // when nothing meets the bar: { id, success } of the best below it, else null
++out.floor; // the bar this answer used (carried by escalate)
++out.escalations; // how many escalations led here (0..2)
++out.excluded; // every rejected id, so escalate can keep them out of the next try
++```
++
++`place()` applies, in this order: the rules and ownership; the success estimate; the floor (an offer below it is
++rejected with `below the bar for code.fix (52% < 60%)`); `plan()` with `success: { [offer_id]: p }`; the line. When
++every offer misses the bar, `result.selected` is `null`, `fallback` is the best one below it, and the line says
++`Nothing meets the bar for code.fix; the best is MiniMax M2 at 52%. Ask before going ahead?` The caller asks the
++person and never goes ahead silently.
++
++## The option key
++
++A route is learned per **option key**: `offer_id` plus the offer's model (or harness) when it carries one.
++
++```js
++optionKey({ offer_id: 'app:codes/claude-code', model: 'opus' }); // 'app:codes/claude-code@opus'
++optionKey({ offer_id: 'node:01J…' }); // 'node:01J…'
++```
++
++Consumers build prior keys and outcome keys with the exported `optionKey(offer)`, never by hand.
++
++## The person's rules
++
++`rules` is a hard filter: never weighed, never traded for price. Each has its own reason in `rejected`.
++
++| Rule | Effect |
++|---|---|
++| `noCloud: true` | only `trust: 'user-owned'` offers owned by `person` |
++| `onlyFree: true` | `max_cost_usd: 0` (free allowances and owned capacity still qualify) |
++| `region: 'eu'` | `residency: 'eu'` |
++| `uncensored: true` | without it, offers with the capability `model:uncensored` are excluded |
++| `maxCostUsd: n` | the lower of this and `requirements.max_cost_usd` |
++| `prefer: 'cheapest' \| 'fastest' \| 'balanced'` | the objective (`cheapest`, `lowest-latency`, `balanced`) unless the requirements name one |
++
++**A user-owned offer belongs to one person.** It is eligible only when its `owner` is the `person` of this step. A
++user-owned offer with no owner, or with another owner, is never eligible; a `place()` call with no `person` cannot
++use one. `plan()` itself does not know the person, so it only applies the contract's trust rule: when
++`requirements.trust` is absent the default is `DEFAULT_TRUST` — first-party, partner, community and external.
++`user-owned` is never a default; it is eligible only when the requirements name it. `TRUST_ORDER` remains the
++five-class preference order.
++
++**A streaming machine.** When an offer says its machine is streaming, a step that asks for a GPU or 2 or more cores
++is excluded with `it is streaming`. The contract does not carry that field yet; `place()` reads
++`offer.health.streaming === true` (and tolerates a top-level `offer.streaming`), so OpenVibe.Node can start sending
++it without another SDK change. Node's status already reports `screen.state`, but Bot's `platform.resource-offer@1`
++does not project it today.
++
++## Learning from outcomes
++
++Counts are plain data, one row per (person, option key, step kind):
++
++```js
++const outcomes = [
++ { person: 'usr_…', key: 'app:codes/claude-code@opus', kind: 'code.fix', ok_w: 9, n_w: 10,
++ updated_at: '2026-10-30T12:00:00.000Z' },
++];
++
++learn(outcomes, { person, key, kind, ok: true, at: now }); // → new outcomes, never mutates
++successOf(outcomes, { person, key, kind, prior, now }); // → p in [0, 1]
++```
++
++Each count decays with a 14-day half-life, applied at read and write time from `updated_at`: what a route did a
++month ago counts for a quarter. The estimate is a Beta mean
++
++```
++p = (k·prior + okP + w·okG) / (k + nP + w·nG)
++```
++
++where `okP`/`nP` are this person's decayed successes and tries, `okG`/`nG` are **everyone else's** for the same pair
++(the person's own rows are not counted twice), `w = min(1, 20 / nG)`, the prior is Bench's score for this key on this
++kind (0.7 when Bench has none), and `k` is 4 with a Bench prior and 2 without one. With no counts for `code.fix` the
++kind falls back to `code`, then to the key's counts over every kind. A successful outcome means the step finished and
++its checks passed (Codes' proof, Run's exit 0, Actor's grounded check); a failure, a timeout or a result the person
++rejected is `ok: false`; a step the person cancelled is not counted.
++
++`place()` takes the outcomes and passes the per-offer estimate to `plan()`. When the success map is present the cost
++part of the score is `estimated_cost_usd / p` (cost per success); `estimated_cost_usd` in the result stays the raw
++cost. When it is absent, `plan()` scores exactly as before.
++
++## Escalation
++
++```js
++escalate(previous, failure, requirements, offers, ctx); // → the next place() answer
++```
++
++`failure.reason` is one of:
++
++- `quality` — the result was wrong: exclude the failed option and raise the floor by 0.1;
++- `busy` or `offline` — exclude it for this step; the floor stays;
++- `refused` or `budget` — a provider said no: exclude every offer from that provider for this step;
++- `timeout` — exclude it and prefer `lowest-latency`.
++
++The failed option is `failure.option_id` (or `failure.id`), else `previous.result.selected`. At most two escalations
++per step; the third failure returns `{ result: { selected: null } }` and a line asking the person.
++
++**The failed try is already learned here, exactly once.** `escalate()` calls `learn()` and returns the new count set as
++`outcomes`. The caller persists that returned set as-is and must not record the same failure again: doing both would
++count one failure twice. Carry the returned `outcomes` into the next `escalate()` and into whatever stores the counts.
++
++## The line
++
++One sentence from fixed templates, built from the two factors that decided the pick. Names come from `names`,
++else the offer's provider and model, else its id. Money is in dollars to 3 significant figures; a percentage is a
++whole number. The line never names another person, a key or a path.
++
++For the person's own free machine the line claims only what the step asked for: `harness:tools` (the harness's own
++tools, including the signed-in CLI) makes it say "your sign-in", and `harness:host-access` (the person's machine and
++its files) makes it say "your files". With neither it says only "your own machine".
++
++| Situation | Template |
++|---|---|
++| own free machine, sign-in and files asked for | `On your desktop: it has your Claude sign-in and your files, and it's free.` |
++| own free machine, sign-in asked for | `On your desktop: it has your Claude sign-in, and it's free.` |
++| own free machine, files asked for | `On your desktop: it has your files, and it's free.` |
++| own free machine, neither asked for | `On your desktop: your own machine, and it's free.` |
++| another machine is streaming | `On the cloud pool: your desktop is streaming, and this needs 4 cores.` |
++| the cheapest that meets the bar | `On DeepSeek V4 Flash: the cheapest that meets the bar ($0.003 a try, 91% expected).` |
++| hysteresis keeps the current one | `Staying on your laptop: the best alternative is only 6% better.` |
++| an escalation moved it | `Moved to the cloud pool: your desktop failed this twice.` |
++| nothing meets the bar | `Nothing meets the bar for code.fix; the best is MiniMax M2 at 52%. Ask before going ahead?` |
++
++`lineFor(facts)` is exported for tests and for a caller that already has the two factors; `place()` and `escalate()`
++build the facts from the result.
ddiff --git a/esm/placement.mjs b/esm/placement.mjs
iindex 57e37d5..b9716d4 100644
---- a/esm/placement.mjs
++++ b/esm/placement.mjs
@@@ -4,6 +4,7 @@ import cjs from '../src/placement.js';
export const {
plan, excluded, marginalCost, priceOf, forecast, ownedLoadCost,
rendezvous, pickTwo, signPlan, verifyPlan, createPlanHolder, canonical,
-- TRUST_CLASSES, TRUST_ORDER,
++ place, escalate, optionKey, learn, successOf, lineFor,
++ TRUST_CLASSES, TRUST_ORDER, DEFAULT_TRUST,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex f1ee088..32e5e22 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.75.0",
++ "version": "0.76.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.75.0",
++ "version": "0.76.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex e71ad69..cee1ccd 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.75.0",
++ "version": "0.76.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex bc74330..4ced16a 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.75.0';
++const SDK_VERSION = '0.76.0';
module.exports = {
createClient,
ddiff --git a/src/placement.js b/src/placement.js
iindex 4455d54..a52d51d 100644
---- a/src/placement.js
++++ b/src/placement.js
@@@ -8,9 +8,14 @@
* const { plan, marginalCost, rendezvous, pickTwo, signPlan, verifyPlan } = require('openvibe-sdk/placement');
* const r = plan(requirements, offers, { rateCards, states, now, current }); // platform.placement-result@1
*
++ * const { place } = require('openvibe-sdk/placement');
++ * const p = place(requirements, offers, { now, person, step: { kind: 'code.fix' }, rules, priors, outcomes });
++ * // → { result, line, ranked, rejected, fallback }: the plan, why, and the one sentence a person reads.
++ *
* The rules:
* - hard constraints first: capabilities, trust, residency/region, health, latency ceiling, capacity, cost ceiling.
-- * A cheaper candidate that misses one is excluded, with the reason, never chosen;
++ * A cheaper candidate that misses one is excluded, with the reason, never chosen. When `requirements.trust` is
++ * absent the default is DEFAULT_TRUST, the contract's four classes: user-owned is never a default;
* - then the objective: `lowest-latency` minimises measured latency within the budget; `cheapest` minimises
* marginal cost within the latency ceiling; `balanced` weighs both; `correctness` keeps the authority (the
* candidate named in `requirements.authority`, or the current placement) and never moves for cost;
@@@ -23,6 +28,9 @@
const crypto = require('crypto');
const TRUST_ORDER = ['first-party', 'user-owned', 'partner', 'community', 'external'];
++// platform.workload-requirements@1: "when absent, first-party, partner, community and external. user-owned is
++// never a default: a user-owned offer is eligible only when this list names it".
++const DEFAULT_TRUST = ['first-party', 'partner', 'community', 'external'];
const HEALTH_OK = new Set(['up', 'degraded']);
// ── Cost ─────────────────────────────────────────────────────────────────────
@@@ -88,7 +96,7 @@ function latencyOf(offer, op) {
function excluded(req, offer) {
const caps = new Set(offer.capabilities || []);
for (const c of req.capabilities || []) if (!caps.has(c)) return `lacks ${c}`;
-- const trust = req.trust && req.trust.length ? req.trust : TRUST_ORDER;
++ const trust = req.trust && req.trust.length ? req.trust : DEFAULT_TRUST;
if (!trust.includes(offer.trust)) return `workload requires ${trust.join(' or ')} trust`;
if (req.residency && offer.region !== req.residency && !(offer.regions || []).includes(req.residency)) return `outside ${req.residency}`;
if (req.region && req.region !== 'nearest' && offer.region !== req.region && offer.region !== 'global') return `not in ${req.region}`;
@@@ -120,11 +128,13 @@ function estimateCost(req, offer, ctx) {
}
/**
-- * plan(requirements, offers, { rateCards, states, now, current, minGain, weights }) → platform.placement-result@1.
++ * plan(requirements, offers, { rateCards, states, now, current, minGain, weights, success }) → platform.placement-result@1.
* rateCards: array or Map by id; states: array or Map by provider (platform.provider-state@1); current: the id
* the workload runs on now (hysteresis); minGain: relative improvement needed to move (default 0.15).
++ * success: optional { [offer_id]: p }, the chance the step succeeds there (0..1). When present, the cost part of
++ * the score is the cost per success (estimated_cost_usd / p); estimated_cost_usd in the result stays the raw cost.
*/
--function plan(req, offers, { rateCards = [], states = [], now = Date.now(), current = null, minGain = 0.15, weights = { cost: 0.5, latency: 0.5 }, owned = {} } = {}) {
++function plan(req, offers, { rateCards = [], states = [], now = Date.now(), current = null, minGain = 0.15, weights = { cost: 0.5, latency: 0.5 }, owned = {}, success = null } = {}) {
const ctx = {
now, owned,
rateCards: rateCards instanceof Map ? rateCards : new Map(rateCards.map((c) => [c.id, c])),
@@@ -141,15 +151,26 @@ function plan(req, offers, { rateCards = [], states = [], now = Date.now(), curr
const lat = latencyOf(o, req.latency_op);
return { id: o.offer_id, eligible: true, estimated_cost_usd: cost, estimated_latency_ms: lat == null ? undefined : lat, trust: o.trust };
});
-- const ok = candidates.filter((c) => c.eligible && Number.isFinite(c.estimated_cost_usd));
++ const probabilities = new Map(success instanceof Map ? success : Object.entries(success || {}));
++ // The cost part of the score is the cost per success when a chance was given; the result keeps the raw cost.
++ const costForScore = (c) => {
++ const p = probabilities.get(c.id);
++ return p != null && p > 0 ? c.estimated_cost_usd / p : c.estimated_cost_usd;
++ };
++ const ok = candidates.filter((c) => {
++ if (!c.eligible || !Number.isFinite(c.estimated_cost_usd)) return false;
++ const p = probabilities.get(c.id);
++ if (p != null && !(p > 0)) { c.eligible = false; c.excluded_because = 'no chance of success'; return false; }
++ return true;
++ });
const reasons = [];
if (!ok.length) return { selected: null, objective, reasons: ['no eligible candidate'], candidates: strip(candidates), decided_at: new Date(now).toISOString() };
-- const maxCost = Math.max(...ok.map((c) => c.estimated_cost_usd), 1e-12);
++ const maxCost = Math.max(...ok.map(costForScore), 1e-12);
const lats = ok.map((c) => c.estimated_latency_ms).filter((x) => x != null);
const maxLat = Math.max(...lats, 1);
for (const c of ok) {
-- const nc = c.estimated_cost_usd / maxCost;
++ const nc = costForScore(c) / maxCost;
const nl = (c.estimated_latency_ms == null ? maxLat : c.estimated_latency_ms) / maxLat;
c.score = objective === 'cheapest' ? nc + nl * 1e-3
: objective === 'lowest-latency' ? nl + nc * 1e-3
@@@ -176,6 +197,382 @@ function strip(cands) {
return cands.map(({ trust, ...c }) => c); // platform.placement-result@1 candidate shape
}
++// ── Placement v1: quality, rules, learning and a reason ─────────────────────
++//
++// place() is plan() with the plan's road: the person's rules and ownership, a quality bar, a success estimate and
++// one sentence saying why. Every function here is pure: now comes in, no clock, no randomness, no I/O.
++
++const DAY_MS = 86400000;
++const HALF_LIFE_DAYS = 14;
++
++/** The option key: offer_id plus the offer's model (or harness) when it carries one, e.g. app:codes/claude-code@opus. */
++function optionKey(offer) {
++ if (!offer) return '';
++ const model = offer.model || (offer.detail && offer.detail.model);
++ const harness = offer.harness || (offer.detail && offer.detail.harness);
++ const tag = model || harness;
++ return tag ? `${offer.offer_id}@${tag}` : String(offer.offer_id == null ? '' : offer.offer_id);
++}
++
++const timeOf = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : Date.parse(v));
++
++/** Exponential decay with a 14-day half-life, from `from` to `to`; a future stamp does not grow a count. */
++function decayCount(count, from, to) {
++ const a = timeOf(from);
++ const b = timeOf(to);
++ const n = Number(count) || 0;
++ if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) return n;
++ return n * (0.5 ** ((b - a) / (HALF_LIFE_DAYS * DAY_MS)));
++}
++
++const rowPerson = (r) => r.person;
++const rowKey = (r) => r.key;
++
++/**
++ * learn(outcomes, { person, key, kind, ok, at }) → new outcomes (never mutates).
++ * Counts are decayed to `at` from their updated_at, then this try is added: ok_w += ok ? 1 : 0, n_w += 1.
++ */
++function learn(outcomes, { person, key, kind, ok, at } = {}) {
++ const atMs = timeOf(at);
++ if (!Number.isFinite(atMs)) throw new Error('learn needs at (ms or ISO date-time)');
++ const source = Array.isArray(outcomes) ? outcomes : [];
++ const next = source.map((r) => ({ ...r }));
++ const row = next.find((r) => rowPerson(r) === person && rowKey(r) === key && r.kind === kind);
++ const stamp = new Date(atMs).toISOString();
++ if (row) {
++ row.ok_w = decayCount(row.ok_w, row.updated_at, atMs) + (ok ? 1 : 0);
++ row.n_w = decayCount(row.n_w, row.updated_at, atMs) + 1;
++ row.updated_at = stamp;
++ } else {
++ next.push({ person, key, kind, ok_w: ok ? 1 : 0, n_w: 1, updated_at: stamp });
++ }
++ return next;
++}
++
++/** The kind chain: exact, then each dotted parent, then every kind (null). */
++function kindChain(kind) {
++ const chain = [];
++ if (kind) {
++ const parts = String(kind).split('.').filter(Boolean);
++ for (let i = parts.length; i > 0; i--) chain.push(parts.slice(0, i).join('.'));
++ }
++ chain.push(null);
++ return chain;
++}
++
++/**
++ * successOf(outcomes, { person, key, kind, prior, now }) → p in [0, 1].
++ * Beta mean: (k·prior + okP + w·okG) / (k + nP + w·nG), with w = min(1, 20 / nG). okG/nG are everyone else's
++ * counts (the person's own rows are not counted twice). Counts decay at read time.
++ * The kind falls back by dotted prefix, then to the key's counts over every kind.
++ */
++function successOf(outcomes, { person, key, kind, prior, now } = {}) {
++ const source = Array.isArray(outcomes) ? outcomes : [];
++ let rows = [];
++ for (const level of kindChain(kind)) {
++ const atLevel = source.filter((r) => rowKey(r) === key && (level == null || r.kind === level));
++ const total = atLevel.reduce((sum, r) => sum + decayCount(r.n_w, r.updated_at, now), 0);
++ if (total > 0) { rows = atLevel; break; }
++ }
++ if (!rows.length) rows = source.filter((r) => rowKey(r) === key); // all-zero counts still keep the person/global split
++
++ let okP = 0; let nP = 0; let okG = 0; let nG = 0;
++ for (const r of rows) {
++ const ok = decayCount(r.ok_w, r.updated_at, now);
++ const n = decayCount(r.n_w, r.updated_at, now);
++ if (rowPerson(r) === person) { okP += ok; nP += n; }
++ else { okG += ok; nG += n; }
++ }
++ const hasPrior = prior != null && Number.isFinite(Number(prior));
++ const k = hasPrior ? 4 : 2;
++ const base = hasPrior ? Number(prior) : 0.7;
++ const w = nG > 0 ? Math.min(1, 20 / nG) : 0;
++ const p = (k * base + okP + w * okG) / (k + nP + w * nG);
++ return Math.max(0, Math.min(1, Number.isFinite(p) ? p : base));
++}
++
++/** A user-owned machine's owner, or the offer itself; never someone else's. */
++function ownedBy(offer, person) {
++ return offer.trust === 'user-owned' && Boolean(person) && offer.owner === person;
++}
++
++/** Whether the machine says it is streaming (the contract has no field yet; Node starts sending health.streaming). */
++function isStreaming(offer) {
++ return Boolean(offer && ((offer.health && offer.health.streaming === true) || offer.streaming === true));
++}
++
++/** "a GPU" or "N cores" when the step needs one of them, else null. */
++function neededResource(req) {
++ const r = req.resources || {};
++ if (r.gpu) return 'a GPU';
++ if (Number(r.cpu) >= 2) return `${Number(r.cpu)} cores`;
++ return null;
++}
++
++// platform.resource-offer@1 / Codes' harness capabilities: host-access is the person's machine and its files;
++// tools are the harness's own tools (the CLI that holds the person's provider sign-in).
++const CAP_HOST_ACCESS = 'harness:host-access';
++const CAP_TOOLS = 'harness:tools';
++
++/** The rules as requirements; each is a hard filter, never weighed. */
++function applyRules(req, rules = {}) {
++ const out = { ...req };
++ if (rules.noCloud) out.trust = ['user-owned'];
++ if (rules.onlyFree) out.max_cost_usd = 0;
++ if (rules.region) out.residency = rules.region;
++ if (rules.maxCostUsd != null) out.max_cost_usd = Math.min(out.max_cost_usd == null ? Infinity : out.max_cost_usd, Number(rules.maxCostUsd));
++ if (rules.prefer && !out.objective) out.objective = rules.prefer === 'fastest' ? 'lowest-latency' : rules.prefer;
++ return out;
++}
++
++/** Why a rule or this call's exclusion list keeps an offer out, in plain words, or null. */
++function ruleExcluded(offer, rules, person, exclude) {
++ if (exclude && exclude[offer.offer_id]) return exclude[offer.offer_id];
++ if (offer.trust === 'user-owned') {
++ if (!person) return 'a user-owned machine needs its owner';
++ if (!offer.owner) return 'a user-owned machine has no owner';
++ if (offer.owner !== person) return "another person's machine";
++ }
++ if (rules.noCloud && !ownedBy(offer, person)) return 'your rule: never send my repo to the cloud';
++ if (rules.uncensored !== true && (offer.capabilities || []).includes('model:uncensored')) return 'uncensored models are off in your rules';
++ return null;
++}
++
++const KEY_LIKE = /\b[a-z]{2,8}_[0-9A-HJKMNP-TV-Z]{26}\b/gi;
++const PATH_LIKE = /(?:^|\s)\/(?:[^/\s]+\/)+[^\s]*/g;
++
++/** A name safe to put in a sentence: no key-like id and no path. */
++function safeName(value) {
++ let s = String(value == null ? '' : value);
++ s = s.replace(KEY_LIKE, 'a machine').replace(PATH_LIKE, ' a machine');
++ return s.replace(/\s+/g, ' ').trim() || 'an option';
++}
++
++/** The offer's name: `names[offer_id]`, else provider and model, else the id. */
++function displayName(offer, names = {}) {
++ if (!offer) return 'an option';
++ if (names && names[offer.offer_id]) return safeName(names[offer.offer_id]);
++ const provider = offer.provider && offer.provider !== 'self' ? offer.provider : null;
++ const model = offer.model || (offer.detail && offer.detail.model);
++ if (provider && model) return safeName(`${provider} ${model}`);
++ if (model) return safeName(model);
++ if (provider) return safeName(provider);
++ return safeName(offer.offer_id);
++}
++
++/** Dollars to 3 significant figures. */
++function money(usd) {
++ const n = Number(usd);
++ if (!Number.isFinite(n)) return 'unpriced';
++ if (n === 0) return '$0';
++ return `$${String(Number(n.toPrecision(3)))}`;
++}
++
++const pct = (p) => Math.round((Number(p) || 0) * 100);
++
++const timesWord = (n) => (n === 1 ? 'once' : n === 2 ? 'twice' : `${n} times`);
++
++function failureText(reason, count = 1) {
++ if (reason === 'quality') return `failed this ${timesWord(count)}`;
++ if (reason === 'busy') return 'was busy';
++ if (reason === 'offline') return 'was offline';
++ if (reason === 'refused' || reason === 'budget') return 'said no';
++ if (reason === 'timeout') return 'timed out';
++ return 'failed';
++}
++
++/**
++ * lineFor(facts) → the one sentence. Fixed templates, no model; facts are the two things that decided the pick.
++ * The line never names another person, a key or a path.
++ */
++function lineFor(f = {}) {
++ const name = safeName(f.name);
++ if (!f.selected) {
++ if (f.fallback) return `Nothing meets the bar for ${safeName(f.step || 'this')}; the best is ${safeName(f.fallback.name)} at ${pct(f.fallback.success)}%. Ask before going ahead?`;
++ return 'Ask the person: nothing can run this.';
++ }
++ if (f.failure) return `Moved to ${name}: ${safeName(f.previousName)} ${failureText(f.failure.reason, f.failure.count || 1)}.`;
++ if (f.staying && f.gain != null) return `Staying on ${name}: the best alternative is only ${pct(f.gain)}% better.`;
++ if (f.streaming) return `On ${name}: ${safeName(f.streaming)} is streaming, and this needs ${f.gpu ? 'a GPU' : `${Number(f.cores)} cores`}.`;
++ if (f.owned && f.free) {
++ const provider = f.provider ? `${safeName(f.provider)} ` : '';
++ if (f.signIn && f.files) return `On ${name}: it has your ${provider}sign-in and your files, and it's free.`;
++ if (f.signIn) return `On ${name}: it has your ${provider}sign-in, and it's free.`;
++ if (f.files) return `On ${name}: it has your files, and it's free.`;
++ return `On ${name}: your own machine, and it's free.`;
++ }
++ if (f.cheapest) return `On ${name}: the cheapest that meets the bar (${money(f.costUsd)} a try, ${pct(f.success)}% expected).`;
++ return `On ${name}: it meets the bar (${money(f.costUsd)} a try, ${pct(f.success)}% expected).`;
++}
++
++/**
++ * place(requirements, offers, ctx) → { result, line, ranked, rejected, fallback, floor, escalations, excluded }.
++ * ctx is plan()'s context plus person, step: {kind}, floor, priors, outcomes, rules and names. The result is exactly
++ * what plan() returns for the offers the rules and the bar left in (validate it as today).
++ */
++function place(requirements, offers, ctx = {}) {
++ const person = ctx.person;
++ const rules = ctx.rules || {};
++ const step = ctx.step || {};
++ const kind = step.kind || requirements.kind || 'this';
++ const floor = ctx.floor == null ? 0.5 : Number(ctx.floor);
++ const req = applyRules(requirements, rules);
++ const rejected = [];
++ const seen = new Set();
++ const reject = (id, why) => { if (!seen.has(id)) { seen.add(id); rejected.push({ id, why }); } };
++
++ const survivors = [];
++ for (const offer of offers) {
++ const why = ruleExcluded(offer, rules, person, ctx.exclude)
++ || (neededResource(req) && isStreaming(offer) ? 'it is streaming' : null);
++ if (why) { reject(offer.offer_id, why); continue; }
++ const key = optionKey(offer);
++ const prior = ctx.priors ? ctx.priors[key] : undefined;
++ survivors.push({ offer, key, success: successOf(ctx.outcomes, { person, key, kind, prior, now: ctx.now }) });
++ }
++
++ const above = [];
++ let fallback = null;
++ for (const c of survivors) {
++ if (c.success < floor) {
++ reject(c.offer.offer_id, `below the bar for ${kind} (${pct(c.success)}% < ${pct(floor)}%)`);
++ if (!fallback || c.success > fallback.success || (c.success === fallback.success && c.offer.offer_id < fallback.id)) {
++ fallback = { id: c.offer.offer_id, success: c.success };
++ }
++ } else above.push(c);
++ }
++
++ const success = {};
++ for (const c of above) success[c.offer.offer_id] = c.success;
++ const result = plan(req, above.map((c) => c.offer), { ...ctx, success });
++ const byId = new Map(result.candidates.map((c) => [c.id, c]));
++ const ranked = [];
++ for (const c of above) {
++ const cand = byId.get(c.offer.offer_id);
++ if (!cand || !cand.eligible || !Number.isFinite(cand.estimated_cost_usd)) {
++ if (cand && !cand.eligible) reject(c.offer.offer_id, cand.excluded_because);
++ continue;
++ }
++ ranked.push({
++ id: c.offer.offer_id, success: c.success, cost_usd: cand.estimated_cost_usd,
++ cost_per_success_usd: c.success > 0 ? cand.estimated_cost_usd / c.success : Infinity,
++ score: cand.score,
++ });
++ }
++ ranked.sort((a, b) => (a.score ?? Infinity) - (b.score ?? Infinity) || (byId.get(a.id).estimated_latency_ms ?? Infinity) - (byId.get(b.id).estimated_latency_ms ?? Infinity) || (a.id < b.id ? -1 : 1));
++ for (const r of ranked) delete r.score;
++
++ const picked = result.selected ? above.find((c) => c.offer.offer_id === result.selected) : null;
++ const asked = new Set(requirements.capabilities || []);
++ let line;
++ if (!picked) {
++ const fb = fallback && offers.find((o) => o.offer_id === fallback.id);
++ line = lineFor({
++ selected: false, step: kind,
++ fallback: above.length === 0 && fallback ? { name: displayName(fb, ctx.names), success: fallback.success } : null,
++ });
++ } else {
++ const cand = byId.get(picked.offer.offer_id);
++ const cps = ranked.find((r) => r.id === picked.offer.offer_id);
++ const min = ranked.length ? Math.min(...ranked.map((r) => r.cost_per_success_usd)) : Infinity;
++ const stuck = ctx.current === picked.offer.offer_id
++ ? (result.reasons || []).find((r) => /only ([\d.]+)% better/.test(r))
++ : null;
++ const gain = stuck ? Number(/only ([\d.]+)%/.exec(stuck)[1]) / 100 : null;
++ let streamingRejected = null;
++ if (neededResource(req)) {
++ streamingRejected = (ctx.current && rejected.find((r) => r.id === ctx.current && r.why === 'it is streaming'))
++ || rejected.find((r) => r.why === 'it is streaming');
++ }
++ const streamingOffer = streamingRejected && offers.find((o) => o.offer_id === streamingRejected.id);
++ line = lineFor({
++ selected: true,
++ name: displayName(picked.offer, ctx.names),
++ owned: ownedBy(picked.offer, person),
++ free: cand.estimated_cost_usd === 0,
++ signIn: asked.has(CAP_TOOLS),
++ files: asked.has(CAP_HOST_ACCESS),
++ provider: picked.offer.provider || (picked.offer.detail && picked.offer.detail.provider),
++ streaming: streamingOffer ? displayName(streamingOffer, ctx.names) : null,
++ cores: req.resources && req.resources.cpu,
++ gpu: Boolean(req.resources && req.resources.gpu),
++ staying: Boolean(stuck),
++ gain,
++ cheapest: cps ? cps.cost_per_success_usd <= min + 1e-12 : false,
++ costUsd: cand.estimated_cost_usd,
++ success: picked.success,
++ step: kind,
++ });
++ }
++
++ return {
++ result, line, ranked,
++ rejected,
++ fallback: above.length === 0 ? fallback : null,
++ floor,
++ escalations: Number(ctx.escalations) || 0,
++ excluded: rejected.map((r) => r.id),
++ };
++}
++
++/**
++ * escalate(previous, failure, requirements, offers, ctx) → the next place() answer, or, after two escalations,
++ * { result: { selected: null }, line: 'Ask the person: …' }. The failed try is learned here exactly once and
++ * returned as `outcomes`: the caller persists that set and must not record the same failure again.
++ */
++function escalate(previous, failure, requirements, offers, ctx = {}) {
++ const prev = previous || {};
++ const attempts = Number(prev.escalations != null ? prev.escalations : ctx.escalations) || 0;
++ const step = ctx.step || { kind: requirements.kind };
++ const kind = step.kind || requirements.kind || 'this';
++ const failedKey = (failure && (failure.option_id || failure.id)) || (prev.result && prev.result.selected) || null;
++ const failed = offers.find((o) => o.offer_id === failedKey || optionKey(o) === failedKey) || null;
++ const failedId = failed ? failed.offer_id : failedKey;
++ let outcomes = ctx.outcomes;
++ if (failedId && ctx.person && ctx.now != null) {
++ outcomes = learn(outcomes, { person: ctx.person, key: optionKey(failed || { offer_id: failedId }), kind, ok: false, at: ctx.now });
++ }
++ if (attempts >= 2) {
++ return {
++ result: { selected: null },
++ line: `Ask the person: three tries failed for ${kind}; should I go ahead?`,
++ escalations: attempts + 1, outcomes: outcomes || [], excluded: prev.excluded || [],
++ };
++ }
++
++ const exclude = { ...(ctx.exclude || {}) };
++ for (const r of prev.rejected || []) if (!exclude[r.id]) exclude[r.id] = r.why;
++ const reason = failure && failure.reason;
++ const why = reason === 'quality' ? 'failed quality on the previous try'
++ : reason === 'busy' ? 'was busy'
++ : reason === 'offline' ? 'was offline'
++ : reason === 'refused' || reason === 'budget' ? 'the provider said no'
++ : reason === 'timeout' ? 'timed out'
++ : 'excluded after a failure';
++ if (failedId) exclude[failedId] = why;
++ if (reason === 'refused' || reason === 'budget') {
++ const provider = (failure && failure.provider) || (failed && failed.provider);
++ if (provider) for (const o of offers) if (o.provider === provider) exclude[o.offer_id] = 'the provider said no';
++ }
++ const floor = reason === 'quality'
++ ? (Number(prev.floor == null ? (ctx.floor == null ? 0.5 : ctx.floor) : prev.floor) + 0.1)
++ : (prev.floor == null ? ctx.floor : prev.floor);
++ const nextReq = reason === 'timeout' ? { ...requirements, objective: 'lowest-latency' } : requirements;
++ const next = place(nextReq, offers, { ...ctx, outcomes, exclude, floor, escalations: attempts + 1 });
++ let line = next.line;
++ if (next.result.selected) {
++ const selected = offers.find((o) => o.offer_id === next.result.selected);
++ const previousOffer = failed || offers.find((o) => o.offer_id === (prev.result && prev.result.selected));
++ line = lineFor({
++ selected: true,
++ name: displayName(selected, ctx.names),
++ failure: { reason, count: attempts + 1 },
++ previousName: displayName(previousOffer, ctx.names),
++ });
++ }
++ return { ...next, line, escalations: attempts + 1, outcomes: outcomes || [] };
++}
++
// ── Stable assignment and local balancing ────────────────────────────────────
/** Weighted rendezvous (highest random weight) hashing: the target for `key`; adding or removing one moves little. */
@@@ -254,7 +651,12 @@ function createPlanHolder(publicKeys, { now = () => Date.now(), onReject = null
};
}
--/** The five trust classes (T1 ADR-046): the order is the planner's preference when no list is named. */
++/** The five trust classes (T1 ADR-046). TRUST_ORDER is the preference order; DEFAULT_TRUST is the contract default. */
const TRUST_CLASSES = ['first-party', 'user-owned', 'partner', 'community', 'external'];
--module.exports = { plan, excluded, marginalCost, priceOf, forecast, ownedLoadCost, rendezvous, pickTwo, signPlan, verifyPlan, createPlanHolder, canonical, TRUST_CLASSES, TRUST_ORDER };
++module.exports = {
++ plan, excluded, marginalCost, priceOf, forecast, ownedLoadCost, rendezvous, pickTwo,
++ signPlan, verifyPlan, createPlanHolder, canonical,
++ place, escalate, optionKey, learn, successOf, lineFor,
++ TRUST_CLASSES, TRUST_ORDER, DEFAULT_TRUST,
++};
ddiff --git a/test/placement-a1.test.js b/test/placement-a1.test.js
nnew file mode 100644
iindex 0000000..3525b8d
---- /dev/null
++++ b/test/placement-a1.test.js
@@@ -0,0 +1,355 @@
++'use strict';
++/**
++ * openvibe-sdk/placement v1 (agent plan A1): rules, ownership, quality and cost per success, learning, escalation and
++ * the line. Fixed numbers: every expectation below is hand-computed in its comment; there is no clock and no I/O.
++ */
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const {
++ place, plan, escalate, optionKey, learn, successOf, lineFor,
++ excluded, TRUST_CLASSES, TRUST_ORDER, DEFAULT_TRUST,
++} = require('../src/placement');
++
++const T = Date.parse('2026-10-30T12:00:00Z');
++const DAY = 86400000;
++const ISO = (ms) => new Date(ms).toISOString();
++const P = `usr_${'A'.repeat(26)}`;
++const Q = `usr_${'B'.repeat(26)}`;
++
++const offer = (id, extra = {}) => ({
++ offer_id: id, kind: 'node', provider: 'pool', region: 'us-west', trust: 'first-party',
++ capabilities: ['worker:browser'], health: { status: 'up' },
++ pricing: { model: 'per-operation', marginal_usd_per_unit: 0.01 },
++ updated_at: '2026-10-30T00:00:00Z', ...extra,
++});
++const req = (o = {}) => ({ kind: 'watch.browser', mobility: 'job', latency_class: 'background', objective: 'cheapest', capabilities: ['worker:browser'], ...o });
++const ctx = (o = {}) => ({ now: T, rateCards: [], states: [], floor: 0, step: { kind: 'code.fix' }, ...o });
++const close = (actual, expected, why) => assert.ok(Math.abs(actual - expected) < 1e-12, `${why}: ${actual} != ${expected}`);
++
++run([
++ ['optionKey adds the model, or the harness, when the offer carries one', async () => {
++ assert.equal(optionKey({ offer_id: 'app:codes/claude-code', model: 'opus' }), 'app:codes/claude-code@opus');
++ assert.equal(optionKey({ offer_id: 'app:codes/claude-code', detail: { model: 'opus' } }), 'app:codes/claude-code@opus');
++ assert.equal(optionKey({ offer_id: 'app:codes/claude-code', detail: { harness: 'claude-code' } }), 'app:codes/claude-code@claude-code');
++ assert.equal(optionKey({ offer_id: 'node:1', model: 'm', harness: 'h' }), 'node:1@m', 'the model wins over the harness');
++ assert.equal(optionKey({ offer_id: 'node:1' }), 'node:1');
++ }],
++
++ ['the default trust is the contract\'s four classes: user-owned is never a default', async () => {
++ assert.deepEqual([...DEFAULT_TRUST], ['first-party', 'partner', 'community', 'external']);
++ assert.deepEqual([...TRUST_CLASSES], ['first-party', 'user-owned', 'partner', 'community', 'external']);
++ assert.deepEqual([...TRUST_ORDER], [...TRUST_CLASSES]);
++ const own = offer('usr-node', { trust: 'user-owned', owner: P, pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } });
++ assert.match(excluded(req(), own), /first-party/);
++ assert.equal(excluded(req({ trust: ['user-owned'] }), own), null);
++ assert.equal(plan(req(), [own, offer('ext')], { now: T }).selected, 'ext', 'a cheaper user-owned offer is not a default');
++ assert.equal(plan(req({ trust: ['user-owned'] }), [own, offer('ext')], { now: T }).selected, 'usr-node');
++ }],
++
++ ['ownership: a user-owned offer is only ever its owner\'s, even when user-owned is named', async () => {
++ const mine = offer('mine', { trust: 'user-owned', owner: P, pricing: { model: 'free-allowance', marginal_usd_per_unit: 0.01 } });
++ const theirs = offer('theirs', { trust: 'user-owned', owner: Q, pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } });
++ const r = place(req({ trust: ['user-owned'] }), [mine, theirs], ctx({ person: P }));
++ assert.equal(r.result.selected, 'mine');
++ assert.deepEqual(r.rejected.find((x) => x.id === 'theirs'), { id: 'theirs', why: "another person's machine" });
++ const noPerson = place(req({ trust: ['user-owned'] }), [mine], ctx({ person: undefined }));
++ assert.equal(noPerson.result.selected, null);
++ assert.equal(noPerson.rejected[0].why, 'a user-owned machine needs its owner');
++ const noOwner = place(req({ trust: ['user-owned'] }), [offer('ownerless', { trust: 'user-owned' })], ctx({ person: P }));
++ assert.equal(noOwner.rejected[0].why, 'a user-owned machine has no owner');
++ }],
++
++ ['rule noCloud: only this person\'s user-owned machine', async () => {
++ const cloud = offer('cloud', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0 } });
++ const mine = offer('mine', { trust: 'user-owned', owner: P, provider: 'Claude', pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } });
++ const theirs = offer('theirs', { trust: 'user-owned', owner: Q, pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } });
++ const r = place(req(), [cloud, mine, theirs], ctx({ person: P, rules: { noCloud: true }, names: { mine: 'your desktop' } }));
++ assert.equal(r.result.selected, 'mine');
++ assert.deepEqual(r.rejected, [
++ { id: 'cloud', why: 'your rule: never send my repo to the cloud' },
++ { id: 'theirs', why: "another person's machine" },
++ ]);
++ assert.equal(r.line, 'On your desktop: your own machine, and it\'s free.');
++ }],
++
++ ['rule onlyFree: max_cost_usd 0, so paid offers are excluded and free allowances still qualify', async () => {
++ const free = offer('free', { pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } });
++ const paid = offer('paid', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.01 } });
++ const r = place(req(), [free, paid], ctx({ person: P, rules: { onlyFree: true } }));
++ assert.equal(r.result.selected, 'free');
++ assert.match(r.rejected.find((x) => x.id === 'paid').why, /over \$0/);
++ }],
++
++ ['rule region: residency is hard', async () => {
++ const us = offer('us', { region: 'us-west' });
++ const eu = offer('eu', { region: 'eu' });
++ const r = place(req(), [us, eu], ctx({ person: P, rules: { region: 'eu' } }));
++ assert.equal(r.result.selected, 'eu');
++ assert.deepEqual(r.rejected.find((x) => x.id === 'us'), { id: 'us', why: 'outside eu' });
++ }],
++
++ ['rule uncensored: without it, model:uncensored offers are excluded', async () => {
++ const unc = offer('unc', { capabilities: ['worker:browser', 'model:uncensored'], pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } });
++ const paid = offer('paid', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.01 } });
++ const off = place(req(), [unc, paid], ctx({ person: P }));
++ assert.equal(off.result.selected, 'paid');
++ assert.deepEqual(off.rejected.find((x) => x.id === 'unc'), { id: 'unc', why: 'uncensored models are off in your rules' });
++ const on = place(req(), [unc, paid], ctx({ person: P, rules: { uncensored: true } }));
++ assert.equal(on.result.selected, 'unc');
++ assert.equal(on.rejected.length, 0);
++ }],
++
++ ['rule maxCostUsd: the lower of the rule and the requirements', async () => {
++ const low = offer('low', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.005 } });
++ const high = offer('high', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.02 } });
++ const r = place(req(), [low, high], ctx({ person: P, rules: { maxCostUsd: 0.01 } }));
++ assert.equal(r.result.selected, 'low');
++ assert.deepEqual(r.rejected.find((x) => x.id === 'high'), { id: 'high', why: 'estimated $0.020000 over $0.01' });
++ const lowerReq = place(req({ max_cost_usd: 0.001 }), [low], ctx({ person: P, rules: { maxCostUsd: 0.01 } }));
++ assert.equal(lowerReq.result.selected, null, 'the requirements\' lower ceiling still wins');
++ }],
++
++ ['rule prefer: an objective unless the requirements name one', async () => {
++ const free = offer('free', { pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } });
++ const paid = offer('paid', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.01 } });
++ const r = place(req({ objective: undefined }), [free, paid], ctx({ person: P, rules: { prefer: 'cheapest' } }));
++ assert.equal(r.result.objective, 'cheapest');
++ const named = place(req({ objective: 'balanced' }), [free, paid], ctx({ person: P, rules: { prefer: 'cheapest' } }));
++ assert.equal(named.result.objective, 'balanced');
++ const fast = place(req({ objective: undefined }), [free, paid], ctx({ person: P, rules: { prefer: 'fastest' } }));
++ assert.equal(fast.result.objective, 'lowest-latency');
++ }],
++
++ ['successOf: the Beta mean, hand-computed (no counts, a Bench prior, personal vs everyone else)', async () => {
++ // No counts and no Bench prior: (2·0.7 + 0) / (2 + 0) = 0.7.
++ close(successOf([], { person: P, key: 'k', kind: 'code.fix', now: T }), 0.7, 'no counts');
++ // A Bench prior 0.8: (4·0.8) / 4 = 0.8.
++ close(successOf([], { person: P, key: 'k', kind: 'code.fix', prior: 0.8, now: T }), 0.8, 'prior only');
++ // This person 9/10; everyone else 6/30 (nG > 20, so w = 2/3 and the personal record gets its due):
++ // (2·0.7 + 9 + (2/3)·6) / (2 + 10 + (2/3)·30) = 14.4 / 32 = 0.45.
++ const counts = [
++ { person: P, key: 'k', kind: 'code.fix', ok_w: 9, n_w: 10, updated_at: ISO(T) },
++ { person: Q, key: 'k', kind: 'code.fix', ok_w: 6, n_w: 30, updated_at: ISO(T) },
++ ];
++ close(successOf(counts, { person: P, key: 'k', kind: 'code.fix', now: T }), 0.45, 'personal counts');
++ // For someone with no rows of their own, everyone else is the whole pool: 15/40, w = 1/2.
++ // (2·0.7 + 15·(1/2)) / (2 + 40·(1/2)) = 8.9 / 22 = 0.40454…, so the personal record wins.
++ close(successOf(counts, { person: `usr_${'C'.repeat(26)}`, key: 'k', kind: 'code.fix', now: T }), 8.9 / 22, 'global only');
++ // The person's own 10 failures are the only rows: global is empty, so the person-only Beta mean is
++ // (2·0.7 + 0) / (2 + 10) = 1.4 / 12 = 0.11666….
++ const onlyMine = [{ person: P, key: 'k', kind: 'code.fix', ok_w: 0, n_w: 10, updated_at: ISO(T) }];
++ close(successOf(onlyMine, { person: P, key: 'k', kind: 'code.fix', now: T }), 1.4 / 12, 'my failures only');
++ }],
++
++ ['successOf: decay at 14 and 28 days', async () => {
++ // 4/8 written 28 days ago. At 14 days ago it has decayed by half: 2/4. Global is empty (the person's own
++ // rows are not counted twice), so P = (2·0.7 + 2) / (2 + 4) = 3.4 / 6 = 0.56666….
++ const old = [{ person: P, key: 'k', kind: 'code.fix', ok_w: 4, n_w: 8, updated_at: ISO(T - 28 * DAY) }];
++ close(successOf(old, { person: P, key: 'k', kind: 'code.fix', now: T - 14 * DAY }), 3.4 / 6, '14 days');
++ // At now it has decayed by a quarter: 1/2. P = (2·0.7 + 1) / (2 + 2) = 2.4 / 4 = 0.6.
++ close(successOf(old, { person: P, key: 'k', kind: 'code.fix', now: T }), 0.6, '28 days');
++ }],
++
++ ['successOf: the kind falls back by dotted prefix, then to every kind', async () => {
++ // No code.fix counts, so the code counts 6/8 are used. Global is empty:
++ // (2·0.7 + 6) / (2 + 8) = 7.4 / 10 = 0.74.
++ const parent = [{ person: P, key: 'k', kind: 'code', ok_w: 6, n_w: 8, updated_at: ISO(T) }];
++ close(successOf(parent, { person: P, key: 'k', kind: 'code.fix', now: T }), 0.74, 'code prefix');
++ close(successOf(parent, { person: P, key: 'k', kind: 'code.fix.tests', now: T }), 0.74, 'two levels up');
++ // No code.fix and no code: the key's counts over every kind are used.
++ const other = [{ person: P, key: 'k', kind: 'other.kind', ok_w: 6, n_w: 8, updated_at: ISO(T) }];
++ close(successOf(other, { person: P, key: 'k', kind: 'code.fix', now: T }), 0.74, 'all kinds');
++ }],
++
++ ['learn: decays to the write time, adds the try, and never mutates its input', async () => {
++ const before = [{ person: P, key: 'k', kind: 'code.fix', ok_w: 2, n_w: 4, updated_at: ISO(T - 14 * DAY) }];
++ const copy = JSON.parse(JSON.stringify(before));
++ const after = learn(before, { person: P, key: 'k', kind: 'code.fix', ok: true, at: T });
++ assert.deepEqual(before, copy, 'the input is untouched');
++ assert.notEqual(after, before);
++ // 2/4 at 14 days decays to 1/2; one success makes 2/3.
++ assert.equal(after[0].ok_w, 2);
++ assert.equal(after[0].n_w, 3);
++ assert.equal(after[0].updated_at, ISO(T));
++ const fresh = learn([], { person: P, key: 'k', kind: 'code.fix', ok: false, at: T });
++ assert.deepEqual(fresh, [{ person: P, key: 'k', kind: 'code.fix', ok_w: 0, n_w: 1, updated_at: ISO(T) }]);
++ }],
++
++ ['plan: cost per success changes the ranking; raw cost in the result does not', async () => {
++ const cheap = offer('cheap', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.01 } });
++ const dear = offer('dear', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.02 } });
++ assert.equal(plan(req(), [cheap, dear], { now: T }).selected, 'cheap', 'without success, the cheap option wins');
++ // Per success: cheap 0.01/0.40 = 0.025; dear 0.02/0.95 = 0.0210526…, so dear wins.
++ const withSuccess = plan(req(), [cheap, dear], { now: T, success: { cheap: 0.4, dear: 0.95 } });
++ assert.equal(withSuccess.selected, 'dear');
++ assert.equal(withSuccess.candidates.find((c) => c.id === 'dear').estimated_cost_usd, 0.02, 'the result keeps the raw cost');
++ assert.equal(plan(req(), [cheap, dear], { now: T, success: {} }).selected, 'cheap', 'an empty map is no change');
++ const r = place(req(), [cheap, dear], ctx({ person: P, floor: 0.3, priors: { cheap: 0.4, dear: 0.95 } }));
++ assert.equal(r.result.selected, 'dear');
++ assert.deepEqual(r.ranked.map((x) => x.id), ['dear', 'cheap']);
++ close(r.ranked.find((x) => x.id === 'cheap').cost_per_success_usd, 0.025, 'cheap per success');
++ close(r.ranked.find((x) => x.id === 'dear').cost_per_success_usd, 0.02 / 0.95, 'dear per success');
++ }],
++
++ ['the floor and the fallback', async () => {
++ const good = offer('good');
++ const bad = offer('bad');
++ const r = place(req({ trust: ['first-party'] }), [good, bad], ctx({ person: P, floor: 0.5, priors: { good: 0.55, bad: 0.4 } }));
++ assert.equal(r.result.selected, 'good');
++ assert.deepEqual(r.rejected, [{ id: 'bad', why: 'below the bar for code.fix (40% < 50%)' }]);
++ assert.equal(r.fallback, null);
++ const none = place(req({ trust: ['first-party'] }), [good, bad], ctx({ person: P, floor: 0.8, priors: { good: 0.4, bad: 0.3 }, names: { good: 'MiniMax M2' } }));
++ assert.equal(none.result.selected, null);
++ assert.deepEqual(none.fallback, { id: 'good', success: 0.4 });
++ assert.equal(none.line, 'Nothing meets the bar for code.fix; the best is MiniMax M2 at 40%. Ask before going ahead?');
++ }],
++
++ ['the line: owned and free claims only the sign-in or files the step asked for', async () => {
++ const own = (caps) => offer('desk', {
++ trust: 'user-owned', owner: P, provider: 'Claude', region: 'home',
++ capabilities: ['worker:browser', ...caps], pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 },
++ });
++ const run = (caps) => place(
++ req({ trust: ['user-owned'], capabilities: ['worker:browser', ...caps] }),
++ [own(caps)],
++ ctx({ person: P, floor: 0.5, priors: { desk: 0.9 }, names: { desk: 'your desktop' } }),
++ );
++ assert.equal(run(['harness:tools', 'harness:host-access']).line,
++ 'On your desktop: it has your Claude sign-in and your files, and it\'s free.');
++ assert.equal(run(['harness:tools']).line, 'On your desktop: it has your Claude sign-in, and it\'s free.');
++ assert.equal(run(['harness:host-access']).line, 'On your desktop: it has your files, and it\'s free.');
++ assert.equal(run([]).line, 'On your desktop: your own machine, and it\'s free.');
++ }],
++
++ ['the line: a streaming machine, and the step needs 4 cores', async () => {
++ const desk = offer('desk', { trust: 'user-owned', owner: P, provider: 'Claude', region: 'home', health: { status: 'up', streaming: true }, pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } });
++ const pool = offer('pool', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.003 } });
++ const r = place(req({ resources: { cpu: 4 } }), [desk, pool], ctx({
++ person: P, current: 'desk', floor: 0.5, priors: { desk: 0.9, pool: 0.91 }, names: { desk: 'your desktop', pool: 'the cloud pool' },
++ }));
++ assert.equal(r.result.selected, 'pool');
++ assert.deepEqual(r.rejected, [{ id: 'desk', why: 'it is streaming' }]);
++ assert.equal(r.line, 'On the cloud pool: your desktop is streaming, and this needs 4 cores.');
++ }],
++
++ ['the line: the cheapest that meets the bar', async () => {
++ const cheap = offer('cheap', { provider: 'deepseek', pricing: { model: 'per-operation', marginal_usd_per_unit: 0.003 } });
++ const dear = offer('dear', { provider: 'other', pricing: { model: 'per-operation', marginal_usd_per_unit: 0.01 } });
++ const r = place(req(), [cheap, dear], ctx({ person: P, floor: 0.5, priors: { cheap: 0.91, dear: 0.8 }, names: { cheap: 'DeepSeek V4 Flash' } }));
++ assert.equal(r.result.selected, 'cheap');
++ assert.equal(r.line, 'On DeepSeek V4 Flash: the cheapest that meets the bar ($0.003 a try, 91% expected).');
++ }],
++
++ ['the line: staying on the current one', async () => {
++ const lap = offer('lap', { latency_ms: { start_p95: 50 } });
++ const alt = offer('alt', { latency_ms: { start_p95: 47 } });
++ const r = place(req({ objective: 'lowest-latency' }), [lap, alt], ctx({ person: P, current: 'lap', floor: 0.5, names: { lap: 'your laptop' } }));
++ assert.equal(r.result.selected, 'lap');
++ assert.equal(r.line, 'Staying on your laptop: the best alternative is only 6% better.');
++ }],
++
++ ['the line: moved after a failure, with the count', async () => {
++ const lap = offer('lap', { trust: 'user-owned', owner: P, provider: 'Claude', pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } });
++ const pool = offer('pool', { provider: 'pool', pricing: { model: 'per-operation', marginal_usd_per_unit: 0.02 } });
++ const other = offer('other', { provider: 'other', pricing: { model: 'per-operation', marginal_usd_per_unit: 0.03 } });
++ const offers = [lap, pool, other];
++ const r = req({ trust: ['user-owned', 'first-party'] });
++ const c = ctx({ person: P, names: { lap: 'your desktop', pool: 'the cloud pool', other: 'the other pool' } });
++ const first = place(r, offers, c);
++ assert.equal(first.result.selected, 'lap');
++ const one = escalate(first, { reason: 'quality' }, r, offers, c);
++ assert.equal(one.result.selected, 'pool');
++ assert.equal(one.line, 'Moved to the cloud pool: your desktop failed this once.');
++ const two = escalate(one, { reason: 'quality' }, r, offers, { ...c, outcomes: one.outcomes });
++ assert.equal(two.result.selected, 'other');
++ assert.equal(two.line, 'Moved to the other pool: the cloud pool failed this twice.');
++ const three = escalate(two, { reason: 'quality' }, r, offers, { ...c, outcomes: two.outcomes });
++ assert.deepEqual(three.result, { selected: null });
++ assert.match(three.line, /^Ask the person: three tries failed/);
++ }],
++
++ ['escalate learns the failed try exactly once, and returns the set the caller persists', async () => {
++ const a = offer('a');
++ const b = offer('b', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.02 } });
++ const c = ctx({
++ person: P, step: { kind: 'code.fix' }, floor: 0.5,
++ outcomes: [{ person: P, key: 'a', kind: 'code.fix', ok_w: 1, n_w: 2, updated_at: ISO(T) }],
++ });
++ const base = place(req(), [a, b], c);
++ assert.equal(base.result.selected, 'a');
++ const esc = escalate(base, { reason: 'busy' }, req(), [a, b], c);
++ // One failure adds exactly one try: a's decayed 1/2 becomes 1/3, and no second row appears.
++ assert.equal(esc.outcomes.length, 1);
++ assert.equal(esc.outcomes.filter((r) => r.key === 'a').length, 1);
++ assert.deepEqual(
++ { ok_w: esc.outcomes[0].ok_w, n_w: esc.outcomes[0].n_w },
++ { ok_w: 1, n_w: 3 },
++ );
++ }],
++
++ ['escalation: each reason, and the stop after 2', async () => {
++ const a1 = offer('a1', { provider: 'paid', pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } });
++ const a2 = offer('a2', { provider: 'paid', pricing: { model: 'per-operation', marginal_usd_per_unit: 0.01 } });
++ const b = offer('b', { provider: 'other', pricing: { model: 'per-operation', marginal_usd_per_unit: 0.02 } });
++ const offers = [a1, a2, b];
++ const c = ctx({ person: P, step: { kind: 'code.fix' }, floor: 0.5 });
++ const base = place(req(), offers, c);
++ assert.equal(base.result.selected, 'a1');
++
++ const busy = escalate(base, { reason: 'busy' }, req(), offers, c);
++ assert.equal(busy.result.selected, 'a2');
++ assert.equal(busy.floor, 0.5, 'busy keeps the floor');
++ assert.equal(busy.rejected.find((x) => x.id === 'a1').why, 'was busy');
++ assert.match(busy.line, /was busy/);
++
++ const offline = escalate(base, { reason: 'offline' }, req(), offers, c);
++ assert.equal(offline.rejected.find((x) => x.id === 'a1').why, 'was offline');
++
++ const refused = escalate(base, { reason: 'refused' }, req(), offers, c);
++ assert.equal(refused.result.selected, 'b');
++ assert.equal(refused.rejected.find((x) => x.id === 'a1').why, 'the provider said no');
++ assert.equal(refused.rejected.find((x) => x.id === 'a2').why, 'the provider said no', 'the whole provider is out');
++ assert.match(refused.line, /said no/);
++
++ const budget = escalate(base, { reason: 'budget' }, req(), offers, c);
++ assert.equal(budget.result.selected, 'b');
++
++ const timeout = escalate(base, { reason: 'timeout' }, req(), offers, c);
++ assert.equal(timeout.result.selected, 'a2');
++ assert.equal(timeout.result.objective, 'lowest-latency');
++ assert.match(timeout.line, /timed out/);
++
++ const quality = escalate(base, { reason: 'quality' }, req(), offers, c);
++ assert.equal(quality.floor, 0.6);
++ assert.equal(quality.escalations, 1);
++ assert.equal(quality.outcomes.length, 1);
++ assert.deepEqual(
++ { key: quality.outcomes[0].key, kind: quality.outcomes[0].kind, ok_w: quality.outcomes[0].ok_w, n_w: quality.outcomes[0].n_w },
++ { key: 'a1', kind: 'code.fix', ok_w: 0, n_w: 1 },
++ );
++ const second = escalate(quality, { reason: 'busy' }, req(), offers, { ...c, outcomes: quality.outcomes });
++ assert.equal(second.escalations, 2);
++ const third = escalate(second, { reason: 'quality' }, req(), offers, { ...c, outcomes: second.outcomes });
++ assert.deepEqual(third.result, { selected: null });
++ assert.equal(third.escalations, 3);
++ }],
++
++ ['the line never names a key or a path', async () => {
++ const key = `usr_${'A'.repeat(26)}`;
++ const r = place(req(), [offer(key, { provider: undefined, pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 } })], ctx({ person: P, floor: 0.5 }));
++ assert.equal(r.result.selected, key);
++ assert.doesNotMatch(r.line, /usr_[0-9A-HJKMNP-TV-Z]{26}/);
++ assert.match(r.line, /^On a machine:/);
++ const named = lineFor({ selected: true, name: '/home/me/project', costUsd: 0.01, success: 0.9, cheapest: false });
++ assert.doesNotMatch(named, /\/home/);
++ }],
++
++ ['determinism: the same inputs give the same answer 100 times', async () => {
++ const cheap = offer('cheap', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.003 } });
++ const dear = offer('dear', { pricing: { model: 'per-operation', marginal_usd_per_unit: 0.01 } });
++ const outcomes = [{ person: P, key: 'cheap', kind: 'code.fix', ok_w: 3, n_w: 4, updated_at: ISO(T - 7 * DAY) }];
++ const c = ctx({ person: P, floor: 0.5, priors: { cheap: 0.91 }, outcomes, names: { cheap: 'Cheap' } });
++ const first = place(req({ objective: 'balanced' }), [cheap, dear], c);
++ for (let i = 0; i < 100; i++) assert.deepEqual(place(req({ objective: 'balanced' }), [cheap, dear], c), first);
++ }],
++]);
ddiff --git a/test/placement.test.js b/test/placement.test.js
iindex 1cdc74b..2570a30 100644
---- a/test/placement.test.js
++++ b/test/placement.test.js
@@@ -44,10 +44,10 @@ run([
const r = plan(req(), [provider('mystery', 'rc_missing')], { now: T });
assert.equal(r.selected, null);
}],
-- ['a user-owned offer is eligible under trust: [\'user-owned\'], and for workloads that name no trust list', async () => {
++ ['a user-owned offer is eligible only under trust: [\'user-owned\'], never by default', async () => {
const own = node('usr-node', 0.2, { trust: 'user-owned' });
assert.equal(excluded(req({ trust: ['user-owned'] }), own), null, 'user-owned trust is accepted');
-- assert.equal(excluded(req(), own), null, 'a workload that names no trust list accepts every TRUST_ORDER class');
++ assert.match(excluded(req(), own), /first-party/, 'the contract default leaves user-owned out');
assert.match(excluded(req({ trust: ['partner'] }), own), /partner/, 'a narrower trust list still refuses it');
const r = plan(req({ objective: 'cheapest', trust: ['user-owned'] }), [own, provider('ext', 'rc')], { rateCards: [card('rc', 'ext', 1e9, 0)], now: T });
assert.equal(r.selected, 'usr-node');
ddiff --git a/types/placement.d.ts b/types/placement.d.ts
iindex 13f90f3..b5eade1 100644
---- a/types/placement.d.ts
++++ b/types/placement.d.ts
@@@ -1,7 +1,10 @@
/** platform.* contract shapes are validated by openvibe-contracts; these are the fields the planner reads. */
--export type Offer = { offer_id: string; kind: 'node' | 'provider'; provider?: string; region: string; regions?: string[]; trust: 'first-party' | 'user-owned' | 'partner' | 'community' | 'external';
-- capabilities: string[]; capacity?: Record<string, any>; latency_ms?: Record<string, number>; health: { status: 'up' | 'degraded' | 'down' | 'draining' };
-- pricing: { model: string; marginal_usd_per_unit?: number; rate_card?: string } };
++export type Offer = { offer_id: string; kind: 'node' | 'provider' | 'storage' | 'delivery' | 'runtime' | 'agent' | 'harness'; provider?: string; model?: string; harness?: string;
++ region: string; regions?: string[]; trust: 'first-party' | 'user-owned' | 'partner' | 'community' | 'external';
++ owner?: string;
++ capabilities: string[]; capacity?: Record<string, any>; latency_ms?: Record<string, number>; health: { status: 'up' | 'degraded' | 'down' | 'draining'; streaming?: boolean };
++ pricing: { model: string; marginal_usd_per_unit?: number; rate_card?: string };
++ detail?: { model?: string; harness?: string; provider?: string; [key: string]: any } };
export type Requirements = { kind: string; mobility: 'request' | 'job' | 'session' | 'stateful-partition'; latency_class: 'realtime' | 'interactive' | 'background' | 'bulk' | 'critical';
objective: 'cheapest' | 'lowest-latency' | 'balanced' | 'private' | 'local-only' | 'first-party-only' | 'high-reliability' | 'correctness';
max_latency_ms?: number; max_cost_usd?: number; residency?: string; region?: string; trust?: string[]; capabilities?: string[];
@@@ -11,7 +14,8 @@ export type ProviderState = { provider: string; period_start: string; period_end
export type Candidate = { id: string; eligible: boolean; excluded_because?: string; estimated_cost_usd?: number; estimated_latency_ms?: number; score?: number };
export type PlacementResult = { selected: string | null; objective: string; reasons: string[]; candidates: Candidate[]; decided_at: string };
export declare function plan(req: Requirements, offers: Offer[], opts?: { rateCards?: RateCard[] | Map<string, RateCard>; states?: ProviderState[] | Map<string, ProviderState>;
-- now?: number; current?: string | null; minGain?: number; weights?: { cost: number; latency: number }; owned?: { busy?: number; full?: number; maxUsd?: number } }): PlacementResult;
++ now?: number; current?: string | null; minGain?: number; weights?: { cost: number; latency: number }; owned?: { busy?: number; full?: number; maxUsd?: number };
++ success?: Record<string, number> | Map<string, number> }): PlacementResult;
export declare function excluded(req: Requirements, offer: Offer): string | null;
export declare function marginalCost(card: RateCard, state: ProviderState | undefined, projected: number, opts?: { now?: number; priority?: 'normal' | 'high' }): number;
export declare function priceOf(card: RateCard, units: number): number;
@@@ -24,6 +28,98 @@ export declare function signPlan(plan: RoutePlan, privateKey: any): RoutePlan;
export declare function verifyPlan(plan: RoutePlan, publicKeys: Record<string, any>, opts?: { now?: number }): { ok: boolean; reason?: string };
export declare function createPlanHolder(publicKeys: Record<string, any>, opts?: { now?: () => number; onReject?: (why: string) => void }): { get(): RoutePlan | null; accept(p: RoutePlan): boolean; targets(route: string): { id: string; weight: number }[] | null };
export declare function canonical(v: unknown): string;
++
++/** One stored (option, kind) count for a person; the counts are decayed with a 14-day half-life. */
++export type Outcome = { person: string; key: string; kind: string; ok_w: number; n_w: number; updated_at: string };
++
++export type PlacementRules = {
++ noCloud?: boolean; onlyFree?: boolean; region?: string; uncensored?: boolean; maxCostUsd?: number;
++ prefer?: 'cheapest' | 'fastest' | 'balanced';
++};
++
++export type PlaceOptions = {
++ rateCards?: RateCard[] | Map<string, RateCard>; states?: ProviderState[] | Map<string, ProviderState>;
++ now?: number; current?: string | null; minGain?: number; weights?: { cost: number; latency: number };
++ owned?: { busy?: number; full?: number; maxUsd?: number };
++ person?: string; step?: { kind: string }; floor?: number;
++ priors?: Record<string, number>; outcomes?: Outcome[]; rules?: PlacementRules;
++ names?: Record<string, string>;
++ /** ids (and their plain reason) an escalation keeps out for this step. */
++ exclude?: Record<string, string>;
++ escalations?: number;
++};
++
++export type RankedOption = { id: string; success: number; cost_usd: number; cost_per_success_usd: number };
++export type RejectedOption = { id: string; why: string };
++
++export type PlaceAnswer = {
++ result: PlacementResult;
++ line: string;
++ ranked: RankedOption[];
++ rejected: RejectedOption[];
++ fallback: { id: string; success: number } | null;
++ floor: number;
++ escalations: number;
++ excluded: string[];
++};
++
++export type PlacementFailure = {
++ reason: 'quality' | 'busy' | 'offline' | 'refused' | 'budget' | 'timeout';
++ option_id?: string;
++ id?: string;
++ provider?: string;
++};
++
++export type EscalationAnswer = {
++ result: PlacementResult | { selected: null };
++ line: string;
++ ranked?: RankedOption[];
++ rejected?: RejectedOption[];
++ fallback?: { id: string; success: number } | null;
++ floor?: number;
++ escalations: number;
++ excluded: string[];
++ outcomes: Outcome[];
++};
++
++export type LineFacts = {
++ selected?: boolean;
++ name?: string;
++ owned?: boolean;
++ free?: boolean;
++ signIn?: boolean;
++ files?: boolean;
++ provider?: string;
++ streaming?: string | null;
++ cores?: number;
++ gpu?: boolean;
++ staying?: boolean;
++ gain?: number | null;
++ cheapest?: boolean;
++ costUsd?: number;
++ success?: number;
++ step?: string;
++ fallback?: { name: string; success: number } | null;
++ previousName?: string;
++ failure?: { reason: PlacementFailure['reason']; count?: number } | null;
++};
++
++/** The option key: offer_id plus the offer's model (or harness) when it carries one. */
++export declare function optionKey(offer: Offer): string;
++/** Add one try to the plain outcome counts, decayed to `at`; returns new data and never mutates the input. */
++export declare function learn(outcomes: Outcome[], entry: { person: string; key: string; kind: string; ok: boolean; at: number | string }): Outcome[];
++/** The Beta-mean success estimate for (person, key, kind), with decay, a Bench prior and everyone else's counts. */
++export declare function successOf(outcomes: Outcome[], opts: { person?: string; key: string; kind?: string; prior?: number; now?: number | string }): number;
++/** The one-sentence explanation from fixed templates. */
++export declare function lineFor(facts: LineFacts): string;
++/** plan() plus the person's rules, ownership, the quality bar, success estimates and the line. */
++export declare function place(req: Requirements, offers: Offer[], opts?: PlaceOptions): PlaceAnswer;
++/** The next place() answer after a failure, or the ask-the-person answer after two escalations. */
++export declare function escalate(previous: PlaceAnswer, failure: PlacementFailure, req: Requirements, offers: Offer[], opts?: PlaceOptions): EscalationAnswer;
++
/** The five trust classes (T1 ADR-046): first-party, user-owned, partner, community, external. */
export declare const TRUST_CLASSES: readonly ['first-party', 'user-owned', 'partner', 'community', 'external'];
++/** The preference order across the five classes (all five). */
export declare const TRUST_ORDER: readonly ['first-party', 'user-owned', 'partner', 'community', 'external'];
++/** platform.workload-requirements@1's default when `trust` is absent: user-owned is never a default. */
++export declare const DEFAULT_TRUST: readonly ['first-party', 'partner', 'community', 'external'];