Files
pepa-pi-bot/runtime/goal/invariants.js
T
c910457817 v0.4.0 vNext — closed-loop world model + settlement contract (#29)
* feat(v0.4.0): vNext — closed-loop world model + settlement contract

Implements the vNext architecture from the research doc: demote the noisy
multi-rail planner in favour of a closed loop (world truth → invariant check)
plus a single utility-driven goal authority.

L1 services (fix no_drop / silent pathfinder hang first):
- InventoryLedger: diff-based "did I actually get it" verifier; acquire-food
  now confirms via ledger.gainedSince instead of the unreliable count/event.
- MotionService.gotoSafe: wall-clock timeout + progress watchdog +
  path_update(noPath/timeout) → structured {reached|stuck|timeout|nopath}.

L3 plan — unify the three competing rails (curriculum/manifesto/storyline):
- Settlement Contract: ordered M0–M9 milestones, each invariant-checked
  against an authoritative world view (early steps delegate to the proven
  curriculum; late game adds farming).
- InvariantChecker + predicate library; GoalManager selects the lowest unmet
  milestone via utility argmax (food-urgency preempts, DEPS-style).
- Wired into the scheduler: bot.js precomputes snapshot.contract; reflex.js
  consumes it in place of the storyline rail. Manifesto L0 still preempts.

Eval + robustness:
- Village Score (single 0..1 metric) on the snapshot + TUI "build" line.
- survive.dig-in skill + dusk_dig_in mode (exposed at night, no bed → cover).
- approach_block helper (GoalNear + lookAt, avoids GoalLookAtBlock #341).

+28 new tests (450 total green). LLM remains entirely off the tick path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v0.4.0): finish vNext plan — anti-loop, skill-graph, worldDelta diff, flee→motion

Completes the remaining v0.4.0 plan items and one fix motivated by a live
in-game observation (flee hanging 30s against a persistent zombie).

- flee → MotionService.gotoSafe: structured {stuck|timeout|nopath} in ~4s with
  a blind-retreat fallback, instead of the observed 30s pathfinder hang + 3
  watchdog replans. Movements setup guarded so it is unit-testable.
- QW5 anti-loop (runtime/anti-loop.js): same skill failing >=3x in 5min →
  30min blacklist (reflex shouldSkip) + one-shot improvement_request
  (bot.js drainFired -> writeProposal).
- 4.1 closed-loop worldDelta: runSkill snapshots inventory before execute and
  attaches the real delta (_invObserved) to every successful result; opt-in
  skill.expectGain asserts the claimed gain or returns world_unchanged.
- 3.6 skill-graph (Plan4MC): declarative requires/produces for ~20 skills;
  prerequisitesMet/canRun/runnableFrontier; GoalManager annotates suggestions
  with blockedBy when prereqs are unmet.

+22 tests (472 total green). Live smoke confirmed dig-in works and no new
errors; flee loop is what this commit's flee migration addresses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:29:39 +03:00

178 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Invariant predicate library (L3).
//
// The research's core diagnosis: the bot picks plausible tasks but never
// asserts whether the world actually moved toward a settlement, so "progress"
// is replaced by noise. The fix is a typed contract whose milestones each
// carry INVARIANTS — boolean predicates over an authoritative world view — so
// "are we done with this milestone" is a fact about the world, not a guess.
//
// A predicate is a plain object: { id, describe, met(world) -> boolean }.
// `world` is the normalised view produced by worldFromSnapshot(): it exposes
// inventory (name->count), locations, health/food, daylight and an optional
// InventoryLedger. Predicates are PURE — they never touch the bot or disk.
// ---- world view ------------------------------------------------------------
export function worldFromSnapshot(snapshot, extra = {}) {
const s = snapshot ?? {};
return {
snapshot: s,
inventory: s.inventory ?? {},
locations: s.locations ?? {},
health: s.health ?? 20,
food: s.food ?? 20,
hasFood: !!s.hasFood,
isDay: s.isDay !== false,
position: s.position ?? null,
nearbyBlocks: s.nearbyBlocks ?? {},
nearbyEntities: s.nearbyEntities ?? {},
closestHostile: s.closestHostile ?? null,
ledger: extra.ledger ?? null,
};
}
// ---- inventory helpers (shared with the contract) --------------------------
export function totalMatching(inv, matcher) {
const f =
typeof matcher === "function"
? matcher
: matcher instanceof RegExp
? (k) => matcher.test(k)
: (k) => k === matcher;
let sum = 0;
for (const [k, n] of Object.entries(inv ?? {})) if (f(k)) sum += n;
return sum;
}
export function totalLogs(inv) {
return totalMatching(inv, (k) => k.endsWith("_log") || k.endsWith("_stem"));
}
export function totalPlanks(inv) {
return totalMatching(inv, (k) => k.endsWith("_planks"));
}
export function totalCobble(inv) {
return (inv?.cobblestone ?? 0) + (inv?.cobbled_deepslate ?? 0);
}
export function totalWool(inv) {
return totalMatching(inv, (k) => k.endsWith("_wool"));
}
export function maxSingleColourWool(inv) {
let best = 0;
for (const [k, n] of Object.entries(inv ?? {})) {
if (k.endsWith("_wool") && n > best) best = n;
}
return best;
}
export function hasAnyBed(inv) {
return totalMatching(inv, (k) => k.endsWith("_bed")) > 0;
}
export function has(inv, name, n = 1) {
return (inv?.[name] ?? 0) >= n;
}
export const WOODEN_TOOLS = ["wooden_axe", "wooden_pickaxe", "wooden_sword"];
export const STONE_TOOLS = ["stone_axe", "stone_pickaxe", "stone_sword"];
export const COOKED_FOODS = [
"bread", "cooked_beef", "cooked_chicken", "cooked_porkchop",
"cooked_mutton", "cooked_rabbit", "baked_potato", "apple",
"carrot", "potato", "cooked_cod", "cooked_salmon",
];
// ---- predicate builders ----------------------------------------------------
export function alive() {
return { id: "alive", describe: "health > 0", met: (w) => w.health > 0 };
}
export function healthAtLeast(n) {
return { id: `health>=${n}`, describe: `health at least ${n}`, met: (w) => w.health >= n };
}
export function foodAtLeast(n) {
return { id: `food>=${n}`, describe: `hunger at least ${n}`, met: (w) => w.food >= n };
}
// "Food security": carrying edible food, or well-fed, or holding a cooked
// staple. We cannot introspect chest contents from the snapshot, so this is
// the observable proxy for research M1's `food_stock>=5_in_chest`.
export function foodSecure() {
return {
id: "food_secure",
describe: "carrying edible food or well-fed",
met: (w) => w.hasFood || w.food >= 18 || COOKED_FOODS.some((n) => has(w.inventory, n)),
};
}
export function hasAllItems(names) {
return {
id: `has_all:${names.join(",")}`,
describe: `carrying all of ${names.join(", ")}`,
met: (w) => names.every((n) => has(w.inventory, n)),
};
}
export function hasItem(matcher, n = 1, label = null) {
return {
id: `has:${label ?? String(matcher)}>=${n}`,
describe: `at least ${n}× ${label ?? String(matcher)}`,
met: (w) => totalMatching(w.inventory, matcher) >= n,
};
}
export function woodenTier() {
return {
id: "wooden_tier",
describe: "wooden axe + pickaxe + sword",
met: (w) => WOODEN_TOOLS.every((n) => has(w.inventory, n)),
};
}
export function stoneTier() {
return {
id: "stone_tier",
describe: "stone axe + pickaxe + sword + furnace",
met: (w) => STONE_TOOLS.every((n) => has(w.inventory, n)) && has(w.inventory, "furnace"),
};
}
export function bedSecured() {
return {
id: "bed",
describe: "a bed on hand or a placed bed location",
met: (w) => hasAnyBed(w.inventory) || !!w.locations.bed,
};
}
// A named location exists in locations.json (set by the skill that builds it:
// village.choose-base → base, build-shelter → shelter, place-chest → chest).
export function locationExists(kind) {
return {
id: `loc:${kind}`,
describe: `a known ${kind} location`,
met: (w) => !!w.locations?.[kind],
};
}
// ---- checker ---------------------------------------------------------------
// Evaluate every invariant of a milestone against the world. Returns
// { met, unmet: [ids], evidence: { [id]: bool } } so the GoalManager can pick
// the lowest unmet milestone and the TUI can show *which* invariant is open.
export function checkInvariants(milestone, world) {
const invs = milestone?.invariants ?? [];
const evidence = {};
const unmet = [];
for (const inv of invs) {
let ok = false;
try {
ok = !!inv.met(world);
} catch {
ok = false;
}
evidence[inv.id] = ok;
if (!ok) unmet.push(inv.id);
}
return { met: unmet.length === 0, unmet, evidence };
}