10-bit-village / src /world.cpp
DevOG's picture
Run 2 cutover: deploy Phase A/B/C quality-epoch engine (post Run-1 archival)
a3f913d verified
Raw History Blame Contribute Delete
21.7 kB
#include "engine/world.h"
#include "cognition/llama_bridge.h" // LlamaBridge, Cognition
#include "cognition/degradation.h" // hearsay_hop, degrade, content_word_delta
#include "cognition/coinage.h" // coined_terms — coinage harvest
#include "infra/db.h" // Database::persist_memory + Step 4 logging
#include <sstream>
#include <cstdlib> // std::abs
#include <cstdio> // snprintf
#include <iterator>
#include <fstream> // §C1 wanderer_tales() lazy-load
namespace tbv {
// Word count, used as CognitionLog's token_count (a rough proxy, not a real LLM tokenizer count
// — fine for instrumentation, this isn't fed back into anything determinism-critical).
static int word_count(const std::string& s) {
std::istringstream iss(s);
std::string w; int n = 0;
while (iss >> w) n++;
return n;
}
static std::string hex64(uint64_t h) {
char buf[20]; snprintf(buf, sizeof(buf), "%016llx", (unsigned long long)h);
return buf;
}
// ============================================================================
// tick() — three stages: DISPATCH (one cognition event) -> APPLY (fold back) ->
// PHYSICS (unchanged from Phase 0). Cognition only runs when a bridge is attached;
// the headless Phase 0 path is therefore byte-identical to before.
// ============================================================================
void WorldState::tick() {
// ---- §C1: world event roll/expire. Gated `if (bridge)` -- headless Phase 0 must never
// reach this, proven by --phase0_gate (byte-identical to the pre-C1 baseline at 6000+
// ticks, past an EVENT_CHECK_EVERY boundary). Runs BEFORE dispatch so a fresh famine is
// visible to this tick's build_perception() call, not one tick late. ----
if (bridge) check_world_event();
// ---- A. DISPATCH: exactly ONE villager does ONE cognition event (LOCK 1). ----
if (bridge) {
VillagerID v = (VillagerID)(current_tick % MAX_VILLAGERS);
dispatch_cognition(v);
}
// ---- B. APPLY: fold back tasks due this tick, in strict total order. ----
std::vector<DeferredTask> tasks_to_apply;
auto ready_it = std::stable_partition(async_queue.begin(), async_queue.end(),
[this](const DeferredTask& t) { return t.fold_tick > current_tick; });
tasks_to_apply.assign(std::make_move_iterator(ready_it),
std::make_move_iterator(async_queue.end()));
async_queue.erase(ready_it, async_queue.end());
std::sort(tasks_to_apply.begin(), tasks_to_apply.end());
for (const auto& task : tasks_to_apply) apply_task(task);
// ---- C. PHYSICS: hunger decay (UNCHANGED — keeps Phase 0 hashes byte-identical). ----
for (uint32_t i = 0; i < MAX_VILLAGERS; ++i) {
int32_t decay = pcg32_random_bounded_r(&pcg32_physics_state[i], 50);
// §C1 famine: double decay while active. Scales the ALREADY-DRAWN result, never
// changes the draw itself (pcg32_random_bounded_r's bound parameter must stay fixed
// at 50 -- rejection sampling means a different bound consumes a different, variable
// number of underlying pcg32 calls, which WOULD desync this stream). Redundant
// `bridge &&` here even though active_event can only ever be set inside the
// bridge-gated check_world_event() above -- cheap, explicit, matches this file's
// existing defense-in-depth style.
if (bridge && active_event == VillageEvent::FAMINE) decay *= 2;
needs[i].hunger -= decay;
if (needs[i].hunger < 0) needs[i].hunger = 0;
}
current_tick++;
}
// §C1 -- ~20 hand-written lines, deterministic pick (own event RNG stream, not text
// generation). Lazy-loaded once, CWD-relative like assets/dict_en.txt.
static const std::vector<std::string>& wanderer_tales() {
static const std::vector<std::string> tales = [] {
std::vector<std::string> out;
std::ifstream f("assets/wanderer_tales.txt");
std::string line;
while (std::getline(f, line)) if (!line.empty()) out.push_back(line);
return out;
}();
return tales;
}
constexpr int32_t WANDERER_IMPORTANCE = 1800; // "above band -> gets retold immediately" (plan §C1)
// ============================================================================
// check_world_event — §C1 roll (every EVENT_CHECK_EVERY ticks) + famine expiry. Only ever
// called from tick() inside `if (bridge)`, so headless Phase 0 never reaches this function
// at all -- not just "the roll happens to not fire," the code is structurally unreachable.
// ============================================================================
void WorldState::check_world_event() {
if (active_event == VillageEvent::FAMINE && current_tick >= event_start_tick + EVENT_DURATION) {
active_event = VillageEvent::NONE;
}
if (active_event != VillageEvent::NONE || current_tick % EVENT_CHECK_EVERY != 0) return;
uint32_t roll = (uint32_t)pcg32_random_bounded_r(&pcg32_event_state, 100);
if (roll < 25) {
active_event = VillageEvent::FAMINE;
event_start_tick = current_tick;
return;
}
if (roll >= 50) return; // 50-99: nothing this window
// WANDERER (25-49): one-shot, no ongoing state. Two more draws off the SAME dedicated
// event stream -- target villager, then which tale -- isolated the same way physics vs
// cognition already is, so this never touches any other stream's consumption order.
const auto& tales = wanderer_tales();
if (tales.empty()) return; // asset missing -- fail quiet, not crash (mirrors coinage.h's
// fallback-empty-set behavior when its dict file is absent)
uint32_t target = (uint32_t)pcg32_random_bounded_r(&pcg32_event_state, (int32_t)MAX_VILLAGERS);
uint32_t idx = (uint32_t)pcg32_random_bounded_r(&pcg32_event_state, (int32_t)tales.size());
MemoryEntry e;
e.mem_id = next_mem_id++;
e.tick = current_tick;
e.actor_id = target;
e.importance = WANDERER_IMPORTANCE;
e.type = MemType::EXPERIENCE;
e.source_depth = 0;
e.origin_mem_id = e.mem_id; // self-origin, same convention as a firsthand ACTION memory
e.text = tales[idx];
std::optional<MemoryEntry> evicted = stores[target].add(e, current_tick);
if (db) {
db->persist_memory(run_id, target, e);
db->log_belief_birth(run_id, e.mem_id, e.type, e.tick, e.importance, e.source_depth,
fnv64_text(e.text));
if (evicted) db->mark_belief_death(run_id, evicted->mem_id, current_tick, evicted->origin_mem_id);
}
}
// ============================================================================
// dispatch_cognition — pick the single event for villager v this tick and resolve
// it inline (strict sequential per Gemini [052]). Priority (R2-resolution):
// 1 survival reflex 2 hearsay(neighbour) 3 dream(lonely+cooldown) 4 action
// ============================================================================
void WorldState::dispatch_cognition(VillagerID v) {
std::vector<VillagerID> nbrs = neighbours(v);
Env env;
env.holding_food = holding_food[v];
env.food_in_reach = holding_food[v]; // no ground-food model in v1
env.neighbour_present = !nbrs.empty();
// Cognition seed for this event (consumes the per-villager cognition stream).
uint32_t r = pcg32_random_r(&pcg32_cognition_state[v]);
uint64_t cseed = splitmix64_avalanche(((uint64_t)r << 16) ^ ((uint64_t)v << 32) ^ current_tick);
bool survival = (needs[v].hunger <= HUNGER_CRIT && holding_food[v]);
// §B1 GROUNDED_FLOOR: every GROUNDED_FLOOR_MODth cognition turn is forced down the
// ACTION path (25% at MOD=4), so firsthand experience isn't starved by dream/hearsay
// priority on the dense grid. Dispatch ordinal k derives from current_tick (this
// villager dispatches at ticks v, v+100, v+200, ... -- k = 0, 1, 2, ...), not from any
// RNG draw, so it doesn't touch stream consumption order. Survival still overrides it.
// (42% was considered, for Hitchhiker's sake. The towel stays packed. — Oji, 07-09)
const uint64_t k = (current_tick - v) / MAX_VILLAGERS;
const bool force_action = ((k % GROUNDED_FLOOR_MOD) == GROUNDED_FLOOR_MOD - 1);
// Dream when cooldown has passed AND the villager is lonely (no neighbour) OR a 1-in-6
// cognition roll hits — so dreams surface even on the dense grid, without starving hearsay.
bool can_dream = !force_action && (current_tick > last_dream_tick[v] + 100) && (nbrs.empty() || (r % 6 == 0));
bool can_hearsay = !force_action && !nbrs.empty();
DeferredTask t;
t.villager_id = v;
t.task_seq_id = task_seq_counter++;
t.birth_tick = current_tick;
t.mem_id = next_mem_id++;
int32_t hunger_snap = needs[v].hunger, social_snap = needs[v].social, safety_snap = needs[v].safety;
// ---- 2. DREAM (cooldown + lonely-or-roll) — checked before hearsay so it isn't starved. ----
if (!survival && can_dream) {
std::vector<std::string> frags;
const auto& mems = stores[v].all();
for (size_t k = mems.size(); k-- > 0 && frags.size() < 3; ) frags.push_back(mems[k].text);
if (!frags.empty()) {
t.kind = TaskKind::DREAM;
t.mtype = MemType::DREAM;
t.target = v;
t.origin_mem_id = t.mem_id; // self-origin: a dream is a new belief
t.fold_tick = current_tick + 20;
t.text = bridge->dream(v, frags, trait_prose(genomes[v]), cseed); // §B2
t.importance = importance(MemType::DREAM, 0, lowest_need(v), t.mem_id, v, current_tick);
last_dream_tick[v] = current_tick;
if (db) {
std::string frag_blob; for (const auto& f : frags) frag_blob += f;
db->log_cognition(run_id, v, current_tick, hex64(fnv64_text(frag_blob)), t.text,
"DREAM", word_count(t.text), genomes[v].pack(),
hunger_snap, social_snap, safety_snap, cseed);
}
async_queue.push_back(std::move(t));
return;
}
// nothing to dream about yet -> fall through
}
// ---- 3. HEARSAY ----
if (!survival && can_hearsay) {
// R1: draw always, index into the ascending-id adjacency list.
uint32_t pick = (uint32_t)pcg32_random_bounded_r(&pcg32_cognition_state[v], (int32_t)nbrs.size());
VillagerID src = nbrs[pick];
const MemoryEntry* m = stores[src].most_salient(current_tick);
if (m) {
// §B3: bump fatigue on the memory just selected -- in-place mutation via mem_id
// lookup, doesn't touch/invalidate `m` (see cognitive_store.h note_retold()).
stores[src].note_retold(m->mem_id);
// MSG_062 §2: degrade the heard memory -> two-segment retell -> forced novelty.
// hearsay_hop is the shared engine/gate path (no logic the gate can't see).
RetellResult rr = hearsay_hop(*bridge, v, *m, genomes[v], current_tick, cseed, &coined_words);
t.kind = TaskKind::HEARSAY;
t.mtype = MemType::HEARSAY;
t.target = src; // actor_id = source of the gossip
t.source_depth = (uint8_t)(m->source_depth + 1);
t.origin_mem_id = m->origin_mem_id; // inherit the belief-lineage root
t.fold_tick = current_tick + 5;
t.text = rr.out_text;
t.importance = importance(MemType::HEARSAY, t.source_depth, lowest_need(v),
t.mem_id, v, current_tick);
if (db) {
// Re-derive the degraded text the listener actually worked from (pure function
// of heard/tick, no RNG — safe to call again here purely for the delta metric).
// Same coined_words set as hearsay_hop's internal degrade() call (§B6-1) --
// otherwise this logged delta wouldn't reflect what was actually masked.
std::string degraded = degrade(*m, current_tick, &coined_words);
int delta = content_word_delta(degraded, rr.out_text);
db->log_hearsay_chain(run_id, t.origin_mem_id, t.origin_mem_id, t.source_depth,
src, v, genomes[src].pack(), genomes[v].pack(),
m->text, rr.out_text, delta, current_tick);
db->log_cognition(run_id, v, current_tick, hex64(fnv64_text(degraded)), rr.out_text,
"HEARSAY", word_count(rr.out_text), genomes[v].pack(),
hunger_snap, social_snap, safety_snap, cseed);
}
async_queue.push_back(std::move(t));
return;
}
// neighbour has nothing to say yet -> fall through to ACTION
}
// ---- 4. ACTION (default + survival path): free thought -> intent -> hybrid resolver. ----
PerceptionContext ctx = build_perception(v, nbrs, env);
Cognition c = bridge->infer(v, ctx, cseed);
t.kind = TaskKind::ACTION;
t.mtype = MemType::EXPERIENCE;
t.verb = c.verb;
t.target = v;
t.origin_mem_id = t.mem_id; // self-origin: a firsthand experience
if ((c.verb == Verb::GIVE || c.verb == Verb::SPEAK) && !nbrs.empty()) {
uint32_t pick = (uint32_t)pcg32_random_bounded_r(&pcg32_cognition_state[v], (int32_t)nbrs.size());
t.target = nbrs[pick];
}
t.fold_tick = current_tick + 5;
t.text = c.thought;
t.importance = importance(MemType::EXPERIENCE, 0, lowest_need(v), t.mem_id, v, current_tick);
if (db) {
db->log_cognition(run_id, v, current_tick, hex64(fnv64_text(ctx.situation)), c.thought,
verb_name(c.verb), word_count(c.thought), genomes[v].pack(),
hunger_snap, social_snap, safety_snap, cseed);
}
async_queue.push_back(std::move(t));
}
// ============================================================================
// apply_task — physical effect (ACTION) + store the memory + persist it.
// ============================================================================
void WorldState::apply_task(const DeferredTask& t) {
if (t.kind == TaskKind::ACTION) {
switch (t.verb) {
case Verb::EAT:
if (holding_food[t.villager_id]) {
needs[t.villager_id].hunger =
std::min(FIXED_POINT_ONE, needs[t.villager_id].hunger + 40000);
holding_food[t.villager_id] = false;
}
break;
case Verb::MOVE_TO: {
int dir = pcg32_random_bounded_r(&pcg32_physics_state[t.villager_id], 8);
static const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
static const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
int nx = (int)pos_x[t.villager_id] + dx[dir];
int ny = (int)pos_y[t.villager_id] + dy[dir];
nx = std::min(std::max(nx, 0), GRID_W - 1);
ny = std::min(std::max(ny, 0), GRID_H - 1);
pos_x[t.villager_id] = (uint16_t)nx;
pos_y[t.villager_id] = (uint16_t)ny;
break;
}
case Verb::GIVE:
if (holding_food[t.villager_id] && t.target != t.villager_id) {
holding_food[t.villager_id] = false;
holding_food[t.target] = true;
needs[t.villager_id].social = std::min(FIXED_POINT_ONE, needs[t.villager_id].social + 20000);
needs[t.target].social = std::min(FIXED_POINT_ONE, needs[t.target].social + 20000);
}
break;
case Verb::SPEAK:
needs[t.villager_id].social = std::min(FIXED_POINT_ONE, needs[t.villager_id].social + 15000);
if (t.target != t.villager_id)
needs[t.target].social = std::min(FIXED_POINT_ONE, needs[t.target].social + 10000);
break;
case Verb::WAIT:
default: break;
}
}
// Store in the actor's own store (for HEARSAY, the listener v remembers the gossip).
MemoryEntry e;
e.mem_id = t.mem_id;
e.tick = t.birth_tick;
e.actor_id = (t.kind == TaskKind::HEARSAY) ? t.target : t.villager_id;
e.importance = t.importance;
e.type = t.mtype;
e.source_depth = t.source_depth;
e.origin_mem_id = t.origin_mem_id;
e.text = t.text;
std::optional<MemoryEntry> evicted = stores[t.villager_id].add(e, current_tick);
if (db) {
db->persist_memory(run_id, t.villager_id, e);
db->log_belief_birth(run_id, e.mem_id, e.type, e.tick, e.importance, e.source_depth,
fnv64_text(e.text));
if (evicted) db->mark_belief_death(run_id, evicted->mem_id, current_tick, evicted->origin_mem_id);
}
// ---- Coinage harvest: flag free-generation tokens absent from the system dictionary
// AND surviving the §B5 inflection/fragment filter (coinage.h::coined_terms). First
// coiner wins; the term then rides hearsay/dream/action text naturally. Adoption
// (a DIFFERENT villager's text reusing an already-coined term, credited once each) feeds
// CoinageSpread — observational for DISPATCH/RNG (still true: nothing here consumes a
// stream draw or changes what runs next tick). §B6-2 (QA-approved, packet 102 item 4)
// deliberately changes the other half of that old claim: `coined_words` now ALSO feeds
// back into generated TEXT via distortion_word() (degradation.h) -- a hash-picked,
// RNG-free read of a set that's itself a pure function of run history, so determinism
// is unaffected, but "coinage never influences anything downstream" is no longer true.
// That feedback loop -- a culture mutating itself via its own invented words -- IS the
// thesis (Research Dossier §B), not a smuggled side-channel. ----
for (const auto& term : coined_terms(t.text)) {
if (coined_words.insert(term).second) {
CoinageOrigin origin;
origin.coiner = t.villager_id;
origin.coiner_genome = genomes[t.villager_id].pack();
origin.birth_tick = t.birth_tick;
coinage_origin[term] = origin;
term_adopters[term].insert(t.villager_id);
if (db) db->register_coinage(run_id, term, t.villager_id, t.birth_tick);
} else if (term_adopters[term].insert(t.villager_id).second && db) {
const CoinageOrigin& origin = coinage_origin[term];
const char* ctx = (t.kind == TaskKind::HEARSAY) ? "hearsay"
: (t.kind == TaskKind::DREAM) ? "dream" : "speech";
db->log_coinage_adoption(run_id, term, origin.coiner, origin.coiner_genome,
origin.birth_tick, t.villager_id, t.birth_tick, ctx);
}
}
}
// ============================================================================
// neighbours — Chebyshev radius 1 (8-neighbourhood), ascending villager_id.
// ============================================================================
std::vector<VillagerID> WorldState::neighbours(VillagerID v) const {
std::vector<VillagerID> r;
int vx = pos_x[v], vy = pos_y[v];
for (uint32_t i = 0; i < MAX_VILLAGERS; ++i) {
if (i == v) continue;
int dx = std::abs((int)pos_x[i] - vx);
int dy = std::abs((int)pos_y[i] - vy);
if (std::max(dx, dy) <= 1) r.push_back(i);
}
return r;
}
// ============================================================================
// build_perception — render real state into a grounded 2nd-person situation.
// ============================================================================
PerceptionContext WorldState::build_perception(VillagerID v,
const std::vector<VillagerID>& nbrs,
const Env& env) const {
PerceptionContext ctx;
ctx.needs = needs[v];
ctx.env = env;
const Genome& g = genomes[v];
std::ostringstream s;
s << trait_prose(g); // §B2: single source of truth, shared with retell()/dream() now
if (needs[v].hunger < 30000) s << "You are very hungry. ";
else if (needs[v].hunger < 60000) s << "You feel peckish. ";
if (needs[v].social < 30000) s << "You feel painfully lonely. ";
if (needs[v].safety < 30000) s << "You feel afraid and on edge. ";
if (env.holding_food) s << "You are holding some food. ";
// §C1: only reaches ACTION-path prompts (build_perception), same scope as trait_prose
// before B2 widened persona to retell/dream -- famine isn't threaded further here, the
// plan only asked for this one line.
if (active_event == VillageEvent::FAMINE) s << "A famine grips the village; food is scarce. ";
if (!nbrs.empty()) {
s << "Nearby stand " << nbrs.size() << " other thronglet" << (nbrs.size() > 1 ? "s" : "") << " (";
for (size_t k = 0; k < nbrs.size(); ++k) { if (k) s << ", "; s << "villager " << nbrs[k]; }
s << "). ";
} else {
s << "You are alone here. ";
}
s << "Think about your situation.";
ctx.situation = s.str();
return ctx;
}
} // namespace tbv