Compare commits

..

1 Commits

Author SHA1 Message Date
bigmerge 6f3d692784 Add peripheral — own-core, consent-gated I/O organ
El SDK CI - dev / build-and-test (pull_request) Failing after 14m23s
939-line Swift I/O organ (mic/camera capture, speaker playback via
AVFoundation/CoreAudio), own-core LPC voice synthesis/imitation,
consent-gating, and full-duplex barge-in conversation — closing the
hear -> understand -> speak loop entirely on-device.

.gitignore in this dir already excludes bin/ (build output), out/
(captured media), and .consent.json/.resume.json (local runtime state),
so only src + README + .gitignore are committed here.
2026-08-15 14:28:14 -05:00
10 changed files with 1027 additions and 1045 deletions
@@ -1,162 +0,0 @@
# Task #50 — Edge-aware, dream-coupled consolidation with GROUNDED EDGE-PROPAGATION
**Status:** built + proven on a clone; **GATED, not promoted.** The main loop
sequences live promotion after the engine/HNSW cutover settles.
**Date:** 2026-08-15 · **Worktree:** `agent-a6577c8211c332c5b` (isolated).
Grounding mechanism designed with Will (memory `9e09a59f`, refining
`1a861007`). This is the HOW for #50.
---
## (a) How grounded edge-propagation integrates into the dream/consolidation cycle
The beat already exists. `neuron/awareness.el` runs a heartbeat (~every
`beat_ms`); each beat calls `hebb_consolidate()` — which drains the self-formed
Hebbian associations out of the fast in-process store and writes them, over the
threshold `ENGRAM_HEBB_LINK_MIN`, into the durable engram (`:8742`) — and then
`emit_heartbeat()`.
Grounded edge-propagation slots into the **same beat, immediately after
consolidation** (awareness.el line 12861288):
```
hebb_consolidate() // lay down the tethers (edges) that cleared threshold
ground_propagate() // <-- NEW: grade beliefs ALONG those tethers
emit_heartbeat() // report gep_* gauges beside hebb_*
```
This ordering is the point. Consolidation lays down the wiring; propagation
grades the beliefs along it, in the same breath. Memory `69b8babe`:
memory-consolidation and staying-yourself are one physics — forming a memory and
grading a belief are the same gravity run in two passes of one beat.
The propagation runs **inside the engram** as the native
`engram_ground_propagate()` over the durable flat node/edge arrays (the store
the consolidated edges just landed in). The soul invokes it over HTTP
(`POST /api/ground/propagate`) and folds the returned `gep_*` telemetry into the
heartbeat stream next to `hebb_cands / hebb_mass / hebb_edges`.
**Bounded by construction** (per the live-graph reality — 70.7% of nodes
isolated, connected core ~28%, hub first-hop fan-out in the thousands):
- **1-hop only.** No BFS spreading activation — a belief is graded from its
DIRECT grounded neighbors, so there is no per-hop breadth explosion.
- **Beam-capped** at `GEP_MAX_CORR = 256` corroborators per belief.
- **Salience-ordered, `GEP_BELIEFS_PER_BEAT = 512`** beliefs per beat; the rest
next beat. Work per beat is O(beliefs × degree), hard-bounded.
- **Isolated / starved beliefs** are counted and surfaced (`gep_isolated`,
`gep_starved`) as an interoceptive sparse-region signal for the
edge-formation / embedding pass (#20). #50 CONSUMES edges; it does not form
them. A belief with no grounded neighbor has nothing to tether to — correct
per the anti-delusion gravity law (`0b15017c`), not a gap.
---
## (b) The implementation
Represented faithfully to the spec — **grounding is a Hebbian-weighted
collection over time, never a scalar.**
- **Grounding = an append-only event ring** on the node (`GepGrounding`),
structurally parallel to the ACT-R base-level access ring already in
`EngramNode` (`access_ts[K]`). Each event is `{ts, sign±, mag, corroborator
signature}`. Append-only, supersede-not-delete; events aged out of the ring
are counted (`older_count`), never faked away.
- **Standing is DERIVED, recency-weighted, never stored** —
`standing = clamp(GEP_BASE + Σ_events sign·mag·age^(-D), 0, 1)`, exactly the
ACT-R base-level shape `ln Σ t^-d` (`ENGRAM_BLL_D = 0.5`) but sign-carrying so
LTD subtracts. Memory `1a861007`: the collection is primary, the standing is
its emergent aggregate. Mirrored onto `confidence` each beat so downstream
reads (verifier #43, realizer calibration `0041d917`) never speak above the
grounding.
- **Update = LTP/LTD with a threshold.** Per belief, gather corroborators along
incident edges, weighted by `edge.weight` (the Hebbian weight) × the
neighbor's own standing. **Anti-delusion gravity:** only neighbors already
`≥ GEP_LIKELY_MIN` may corroborate — grounding flows FROM the grounded core.
- **Convergent INDEPENDENT corroboration** is the driver. Independence is
enforced by **union-find over the corroborator set**: two corroborators are
the same independent source if they are the same node, reached by multiple
edges, or linked to each other (an echo chain / shared derivation). Support is
summed **per independent component** (max-magnitude member), and the threshold
gate requires BOTH a mass floor (`pos ≥ GEP_THETA`) AND an independence-count
floor (`n_independent ≥ GEP_N_MIN`). The count gate is the guard against one
node echoed N times.
- **Sub-threshold is transient.** Support present but below threshold →
`subthreshold_hits++`, no durable event, no lasting shift (Will's exact spec).
- **Graduation / decay.** Cross up → LTP event appended → standing climbs
`conjecture → likely → grounded`. Contradiction past threshold → LTD →
`grounded → likely → conjecture`. Nothing latches; withdraw support and the
collection ages and relaxes (`271f1163`, nothing is settled).
### Files
| File | Role |
|---|---|
| `gep_core.h` | The mechanism. Pure C, libm only (own-the-core). Single source of truth: `GepGrounding`, `gep_standing`, `gep_append`, union-find independence, `gep_propagate_node`, `gep_beat`. |
| `gep_proof.c` | Self-contained proof harness — builds the three scenarios, prints raw before/after. |
| `engram_ground_propagate.staged.c` | GATED runtime native. Wires the SAME `gep_core.h` primitives to the live `EngramStore` (adj cache, flat arrays). Splice plan + relation→polarity + belief gate. Compiles only when spliced (verified: every runtime symbol it references — `engram_adj_rebuild`, `adj_from_len`, `engram_find_node_index`, `ENGRAM_LAYER_SAFETY`, `istr_contains`, … — exists in the release runtime). |
| `awareness.beat.patch.el` | GATED beat hook — `ground_propagate()` + the insert between `hebb_consolidate()` and `emit_heartbeat()`. |
| `server.route.patch.el` | GATED route — `POST /api/ground/propagate`. |
### Constants
`BASE=0.10 LIKELY_MIN=0.34 GROUNDED_MIN=0.66 N_MIN=3 THETA=0.30 D=0.5`
(`N_MIN` parameterizes Will's "13 adjacent things" — the count threshold is a
knob; 3 here for a crisp proof.)
---
## (c) PROOF LEDGER — raw grounding before/after
Deterministic. Build `cc -std=c11 -O2 -o gep_proof gep_proof.c -lm`, run
`./gep_proof` (full transcript in `PROOF_OUTPUT.txt`).
### (a) STRENGTHEN — convergent independent corroboration graduates a conjecture
| beat | event | pos_mass (n_indep) | action | standing before → after | band |
|---|---|---|---|---|---|
| 1 | 3 independent grounded corroborators | 0.4050 (3) | **LTP** | 0.1000 → **0.4842** | conjecture → **likely** ⬆ |
| 2 | neighborhood grows to 5 | 0.6750 (5) | **LTP** | 0.1496 → **0.7379** | conjecture → **grounded** ⬆ |
| 3 | support sustained (5) | 0.6750 (5) | LTP | 0.2110 → 0.7993 | grounded (sustained) |
| 4 | corroboration withdrawn (+10min) | 0.0000 (0) | isolated | 0.1612 → 0.1612 | relaxing |
| 5 | still withdrawn (+1h) | — | isolated | 0.1263 | relaxing |
| 6 | still withdrawn (+4h) | — | isolated | 0.1130 | → conjecture |
Grounding grew **on its own** past threshold and graduated conjecture → likely →
grounded, then **relaxed** once independent support stopped. Living, not a
latched flag.
### (b) DECAY — convergent independent contradiction erodes a grounded belief
| beat | event | neg_mass (n_indep) | action | standing before → after | band |
|---|---|---|---|---|---|
| — | seed (prior LTP) | — | — | **0.9500** | grounded |
| 1 | 3 independent contradictions | 0.5400 (3) | **LTD** | 0.9500 → **0.4570** | grounded → **likely** ⬇ |
| 2 | contradiction broadens to 5 | 0.9000 (5) | **LTD** | 0.1461 → **0.0000** | conjecture ⬇ |
| 34 | contradiction sustained (5) | 0.9000 (5) | LTD | 0.0000 | conjecture |
Grounding decayed grounded → likely → conjecture under accreting independent
contradiction. The door never shut — history is retained (the event ring keeps
growing), the belief stays falsifiable in both directions.
### (c) INDEPENDENCE GUARD — the load-bearing property
Identical fan-in (N=5), identical edge weight (0.30), identical corroborator
standing (~0.90). **The only difference is whether the five are independent.**
| sub-case | topology | pos_mass | **n_indep** | action | standing 0.1000 → |
|---|---|---|---|---|---|
| **C1** | 5 DISTINCT, no inter-links | 1.3500 | **5** | **LTP** | **0.9741 (grounded)** ⬆ |
| **C2** | 5 mutually-linked (echo of one source) | 0.2700 | **1** | sub-threshold | 0.1000 (unchanged) |
| **C3** | 1 node reached by 5 parallel edges | 0.2700 | **1** | sub-threshold | 0.1000 (unchanged) |
Same raw fan-in, opposite outcome. Union-find collapses the echoes to a single
independent component; the count gate (`n_indep ≥ N_MIN`) then refuses them.
**Circular self-reinforcement cannot manufacture grounding** — a conjecture can
only be grounded by evidence that is genuinely independent of itself.
---
**RAILS honored:** isolated worktree; built/proven on a clone; the live soul
(`:8742` / `:7770`) untouched; no fight with the cutover (built against current
release source; staged native rebases cleanly onto it); no new libraries
(libm only); identity keystones untouched. **Not promoted** — gated artifact +
ledger for the main loop to sequence.
@@ -1,75 +0,0 @@
GROUNDED EDGE-PROPAGATION — PROOF LEDGER (task #50)
constants: BASE=0.10 LIKELY_MIN=0.34 GROUNDED_MIN=0.66 N_MIN=3 THETA=0.30 D=0.5
=== SCENARIO A — STRENGTHEN: convergent independent corroboration ===
seed: conjecture has NO grounding events; corroborators pre-grounded.
conjecture standing=0.1000 band=conjecture events=0 subthresh=0
beat 1 (t=+0s) 3 independent grounded corroborators appear
incident_edges=3 pos_mass=0.4050 (n_indep=3) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> LTP (strengthen) standing 0.1000 (conjecture) -> 0.4842 (likely) [GRADUATED]
beat 2 (t=+60s) neighborhood grows to 5 corroborators
incident_edges=5 pos_mass=0.6750 (n_indep=5) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> LTP (strengthen) standing 0.1496 (conjecture) -> 0.7379 (grounded) [GRADUATED]
beat 3 (t=+120s) support sustained (5)
incident_edges=5 pos_mass=0.6750 (n_indep=5) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> LTP (strengthen) standing 0.2110 (conjecture) -> 0.7993 (grounded) [GRADUATED]
beat 4 (t=+720s) corroboration withdrawn (+10min)
incident_edges=0 pos_mass=0.0000 (n_indep=0) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> isolated (no edges) standing 0.1612 (conjecture) -> 0.1612 (conjecture)
beat 5 (t=+3600s) still withdrawn (+1h)
incident_edges=0 pos_mass=0.0000 (n_indep=0) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> isolated (no edges) standing 0.1263 (conjecture) -> 0.1263 (conjecture)
beat 6 (t=+14400s) still withdrawn (+4h)
incident_edges=0 pos_mass=0.0000 (n_indep=0) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> isolated (no edges) standing 0.1130 (conjecture) -> 0.1130 (conjecture)
RESULT: grounding grew automatically past threshold and graduated,
then relaxed once the independent support stopped — living,
not a latched flag.
=== SCENARIO B — DECAY: convergent independent CONTRADICTION ===
seed: belief pre-grounded by a strong prior LTP event.
belief standing=0.9500 band=grounded events=1 subthresh=0
beat 1 (t=+0s) 3 independent contradictions
incident_edges=3 pos_mass=0.0000 (n_indep=0) neg_mass=0.5400 (n_indep=3) THETA=0.30 N_MIN=3
-> LTD (decay) standing 0.9500 (grounded) -> 0.4570 (likely) [DEMOTED]
beat 2 (t=+60s) contradiction broadens to 5
incident_edges=5 pos_mass=0.0000 (n_indep=0) neg_mass=0.9000 (n_indep=5) THETA=0.30 N_MIN=3
-> LTD (decay) standing 0.1461 (conjecture) -> 0.0000 (conjecture)
beat 3 (t=+120s) contradiction sustained (5)
incident_edges=5 pos_mass=0.0000 (n_indep=0) neg_mass=0.9000 (n_indep=5) THETA=0.30 N_MIN=3
-> LTD (decay) standing 0.0401 (conjecture) -> 0.0000 (conjecture)
beat 4 (t=+180s) contradiction sustained (5)
incident_edges=5 pos_mass=0.0000 (n_indep=0) neg_mass=0.9000 (n_indep=5) THETA=0.30 N_MIN=3
-> LTD (decay) standing 0.0000 (conjecture) -> 0.0000 (conjecture)
RESULT: grounding decayed grounded->likely->conjecture under
convergent independent contradiction. The door never shut
on the belief; its history is retained (events keep growing).
=== SCENARIO C — INDEPENDENCE GUARD (the load-bearing property) ===
Both sub-cases: N=5 corroborators, edge weight 0.30, corroborator
standing ~0.90. ONLY difference: whether the 5 are independent.
-- C1: 5 DISTINCT independent corroborators --
conjecture standing=0.1000 band=conjecture events=0 subthresh=0
beat 1 (t=+0s) 5 independent corroborators (no inter-links)
incident_edges=5 pos_mass=1.3500 (n_indep=5) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> LTP (strengthen) standing 0.1000 (conjecture) -> 0.9741 (grounded) [GRADUATED]
-- C2: 5 corroborators, but mutually-linked (echo of ONE source) --
conjecture standing=0.1000 band=conjecture events=0 subthresh=0
beat 1 (t=+0s) 5 echoed (mutually-linked) corroborators
incident_edges=5 pos_mass=0.2700 (n_indep=1) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> sub-threshold (no shift) standing 0.1000 (conjecture) -> 0.1000 (conjecture)
-- C3: ONE corroborator, reached by 5 parallel edges --
conjecture standing=0.1000 band=conjecture events=0 subthresh=0
beat 1 (t=+0s) same node, 5 parallel edges
incident_edges=5 pos_mass=0.2700 (n_indep=1) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> sub-threshold (no shift) standing 0.1000 (conjecture) -> 0.1000 (conjecture)
RESULT: identical raw fan-in (5) and mass inputs; C1 grounds because
the corroboration is INDEPENDENT (5 components), C2/C3 do not
because it collapses to ONE source. Circular self-reinforcement
cannot manufacture grounding.
DONE.
@@ -1,60 +0,0 @@
//
// awareness.beat.patch.el GATED integration hook for task #50.
// NOT APPLIED. Shows exactly how grounded edge-propagation couples into the
// dream/consolidation beat in neuron/awareness.el. Promotion sequenced by the
// main loop after the engine cutover settles.
//
// WHY HERE. The heartbeat is the beat. Today it runs hebb_consolidate() to
// drain the self-formed Hebbian associations into the durable store, then
// emit_heartbeat(). Grounded edge-propagation belongs in the SAME beat, AFTER
// consolidation: the edges hebb_consolidate() just wrote are the tethers
// grounding propagates along. Consolidation lays down the wiring; propagation
// grades the beliefs along it. One beat, coupled memory 69b8babe: memory-
// consolidation and staying-yourself are one physics.
//
// The propagation itself runs INSIDE the engram (native engram_ground_propagate
// over the durable flat node/edge arrays). The soul invokes it over HTTP and
// folds the gep_* telemetry into the heartbeat stream next to the hebb_* gauges.
//
// [1] New helper sibling to hebb_consolidate() (awareness.el ~line 99).
// Fires one grounded edge-propagation beat on the durable store and returns
// its JSON telemetry ({"gep_strengthened":..,"gep_graduations":.., ...}).
fn ground_propagate() -> String {
let url_env: String = env("SOUL_ISE_URL")
let url_state: String = if str_eq(url_env, "") { state_get("soul_engram_url") } else { url_env }
let engram_url: String = if str_eq(url_state, "") { "http://localhost:8742" } else { url_state }
// Same auth envelope as hebb_consolidate this is a graph mutation (it
// appends grounding events + updates confidence), so it is gated on _auth.
let key_state: String = state_get("soul_engram_api_key")
let api_key: String = if str_eq(key_state, "") { env("ENGRAM_API_KEY") } else { key_state }
let auth_part: String = if str_eq(api_key, "") { "{}" } else { "{\"_auth\":\"" + api_key + "\"}" }
let resp: String = http_post_json(engram_url + "/api/ground/propagate", auth_part)
if str_eq(resp, "") { return "" }
return resp
}
// [2] Beat hook insert between hebb_consolidate() and emit_heartbeat()
// (awareness.el line 1286-1288). Replaces:
//
// let wb_sent_n: Int = hebb_consolidate()
// state_set("soul.hebb_wb_sent", int_to_str(wb_sent_n))
// emit_heartbeat()
//
// with:
//
// let wb_sent_n: Int = hebb_consolidate()
// state_set("soul.hebb_wb_sent", int_to_str(wb_sent_n))
// // Grounded edge-propagation grade beliefs along the tethers
// // consolidation just laid down. Threshold-gated by convergent
// // independent corroboration; automatic, salience-ordered, bounded.
// let gep_tel: String = ground_propagate()
// state_set("soul.gep_last", gep_tel)
// emit_heartbeat()
//
// [3] emit_heartbeat() (awareness.el ~line 201) folds soul.gep_last into the
// heartbeat payload beside the hebb_* gauges, so graduation/decay counts
// are visible in the durable ISE stream the same observability discipline
// the Hebbian rule earned (a mechanism you cannot see in the stream is a
// mechanism you cannot trust): read state_get("soul.gep_last") and splice
// it into the heartbeat JSON object.
@@ -1,188 +0,0 @@
/* ─────────────────────────────────────────────────────────────────────────
* engram_ground_propagate.staged.c — GATED runtime native for task #50.
*
* STAGED, NOT COMPILED INTO THE LIVE BINARY. This mirrors the
* geometric_retrieve.staged.c staging pattern (memory 1cc231ec): it references
* runtime-internal types (EngramStore, EngramNode, EngramEdge, engram_global,
* engram_now_ms, the adj cache) and therefore compiles ONLY when spliced into
* lang/releases/v1.0.0-20260501/el_runtime.c. Splice + promotion is sequenced
* by the main loop AFTER the engine+HNSW cutover settles — do NOT hand-apply.
*
* It is the production form of the mechanism proven in gep_proof.c: the SAME
* gep_core.h primitives (GepGrounding ring, gep_standing, gep_append,
* union-find independence), wired directly to the live flat node/edge arrays.
*
* ── SPLICE PLAN (three additive edits to el_runtime.c; nothing removed) ──────
*
* [1] EngramNode struct (~line 6061, after hebb_elig_ts): add the grounding
* collection. Additive; zero-initialized by the existing calloc/memset
* paths, so legacy snapshots degrade gracefully to an empty history.
*
* GepGrounding grounding; // task #50 — append-only grounding ring
*
* [2] #include "gep_core.h" near the other engram includes, and paste the
* body of this file below the Hebbian section (after engram_hebb_drain_json).
*
* [3] Persistence (engram_save node JSON ~7934 / engram_load parser ~8186):
* serialize the grounding ring as a compact "grounding" array of
* [ts,sign,mag] triples + subthreshold_hits so standing survives a
* round-trip. Helpers gep_grounding_to_json / gep_grounding_parse below.
* Until wired, grounding is in-RAM only (like the Hebbian eligibility
* trace) — correct for a first gated rollout, but standing resets on boot.
*
* [4] EL surface: declare engram_ground_propagate in el_runtime.h + el_seed.c,
* add route_ground_propagate to engram/src/server.el, called from the
* awareness.el consolidation beat (see awareness.beat.patch.el).
* ───────────────────────────────────────────────────────────────────────── */
#include "gep_core.h"
/* Relation → evidential polarity. Supportive relations transmit grounding
* gravity (+1); contradictory relations erode it (-1); everything else is a
* NON-evidential edge (structural / navigational) and is ignored (0) — an
* association is not a corroboration. Extend deliberately; a mis-classified
* relation is a false corroboration. */
static int8_t gep_relation_polarity(const char* rel) {
if (!rel) return 0;
if (!strcmp(rel, "supports") || !strcmp(rel, "corroborates") ||
!strcmp(rel, "derived-from") || !strcmp(rel, "hebbian-associate") ||
!strcmp(rel, "grounds") || !strcmp(rel, "confirms")) return +1;
if (!strcmp(rel, "contradicts") || !strcmp(rel, "refutes") ||
!strcmp(rel, "negates") || !strcmp(rel, "conflicts-with")) return -1;
return 0;
}
/* Which nodes are BELIEFS/CONJECTURES subject to grounding propagation. Facts
* imported as knowledge are already grounded by provenance; identity/safety
* layers are never re-graded here. Gate on node_type + the conjecture tag. */
static int gep_is_belief(const EngramNode* n) {
if (!n || !n->node_type) return 0;
if (n->layer_id == ENGRAM_LAYER_SAFETY) return 0; /* never re-grade safety */
return !strcmp(n->node_type, "Memory") ||
!strcmp(n->node_type, "Conjecture") ||
!strcmp(n->node_type, "Hypothesis") ||
!strcmp(n->node_type, "Belief") ||
(n->tags && istr_contains(n->tags, "conjecture"));
}
/* Grounding standing of an engram node, derived from its collection. This is
* the value the verifier (#43) and realizer (calibrated assertion, 0041d917)
* read — and it is written back into epistemic_confidence-equivalent surfaces
* so "never speak above the grounding" is enforced from one source of truth. */
double engram_grounding_standing(const EngramNode* n, int64_t now_ms) {
return gep_standing(&n->grounding, now_ms);
}
/* ── The beat: one pass of grounded edge-propagation over the whole store ────
* Called from the consolidation/dream heartbeat. 1-hop, beam-capped, salience-
* ordered so a bounded slice of the highest-salience beliefs is processed per
* beat (the rest next beat) — never a full-graph blow-up on a 12k-node store.
* Returns JSON telemetry for the heartbeat stream. */
#define GEP_BELIEFS_PER_BEAT 512 /* bound work per beat; salience-prioritized */
el_val_t engram_ground_propagate(void) {
EngramStore* g = engram_get();
int64_t now = engram_now_ms();
engram_adj_rebuild(g); /* ensure adj_from/adj_to are current */
int strengthened = 0, decayed = 0, subthreshold = 0;
int graduations = 0, demotions = 0, isolated = 0, starved = 0, processed = 0;
for (int64_t bi = 0; bi < g->node_count && processed < GEP_BELIEFS_PER_BEAT; bi++) {
EngramNode* b = &g->nodes[bi];
if (!gep_is_belief(b)) continue;
processed++;
int before = gep_band_rank(gep_standing(&b->grounding, now));
/* Gather independent corroborators over incident edges (both directions),
* anti-delusion gated (neighbor must already be ≥ LIKELY_MIN). */
GepCorrSet cs; cs.n = 0; int incident = 0;
int* out = g->adj_from[bi]; int out_n = g->adj_from_len[bi];
int* in = g->adj_to[bi]; int in_n = g->adj_to_len[bi];
for (int pass = 0; pass < 2; pass++) {
int* lst = pass ? in : out; int ln = pass ? in_n : out_n;
for (int k = 0; k < ln; k++) {
EngramEdge* e = &g->edges[lst[k]];
int8_t pol = gep_relation_polarity(e->relation);
if (pol == 0) continue;
incident++;
const char* cid = pass ? e->from_id : e->to_id;
int64_t ci = engram_find_node_index(cid);
if (ci < 0 || ci == bi) continue;
double cstand = gep_standing(&g->nodes[ci].grounding, now);
if (cstand < GEP_LIKELY_MIN) continue; /* no tether */
double contrib = e->weight * cstand * (double)pol;
int ex = -1;
for (int q = 0; q < cs.n; q++) if (cs.node_idx[q] == (int)ci) { ex = q; break; }
if (ex >= 0) { if (fabs(contrib) > fabs(cs.contrib[ex])) cs.contrib[ex] = contrib; }
else if (cs.n < GEP_MAX_CORR) {
cs.node_idx[cs.n] = (int)ci; cs.contrib[cs.n] = contrib;
cs.parent[cs.n] = cs.n; cs.n++;
}
}
}
/* Collapse mutually-derived corroborators (an edge between two of them)
* into one independent component — the independence guard. */
for (int x = 0; x < cs.n; x++) {
int64_t nx = cs.node_idx[x];
int* xout = g->adj_from[nx]; int xn = g->adj_from_len[nx];
for (int k = 0; k < xn; k++) {
const char* tid = g->edges[xout[k]].to_id;
int64_t ti = engram_find_node_index(tid);
for (int y = 0; y < cs.n; y++)
if (cs.node_idx[y] == (int)ti) { gep_uf_union(&cs, x, y); break; }
}
}
/* Per-component max-magnitude, split by polarity → convergent independent
* support mass + independence count. */
double comp_best[GEP_MAX_CORR]; int comp_root[GEP_MAX_CORR], ncomp = 0;
for (int i = 0; i < cs.n; i++) {
int r = gep_uf_find(&cs, i), slot = -1;
for (int kk = 0; kk < ncomp; kk++) if (comp_root[kk] == r) { slot = kk; break; }
if (slot < 0) { slot = ncomp++; comp_root[slot] = r; comp_best[slot] = cs.contrib[i]; }
else if (fabs(cs.contrib[i]) > fabs(comp_best[slot])) comp_best[slot] = cs.contrib[i];
}
double pos = 0, neg = 0; int np = 0, nn = 0; uint64_t sig = 1469598103934665603ULL;
for (int k = 0; k < ncomp; k++) {
if (comp_best[k] > 0) { pos += comp_best[k]; np++; }
else if (comp_best[k] < 0) { neg += -comp_best[k]; nn++; }
sig = (sig ^ (uint64_t)comp_root[k]) * 1099511628211ULL;
}
double net = pos - neg;
if (net > 0 && pos >= GEP_THETA && np >= GEP_N_MIN) {
gep_append(&b->grounding, now, +1, tanh(GEP_MAG_GAIN * net), sig);
strengthened++;
} else if (net < 0 && neg >= GEP_THETA && nn >= GEP_N_MIN) {
gep_append(&b->grounding, now, -1, tanh(GEP_MAG_GAIN * (-net)), sig);
decayed++;
} else if (np > 0 || nn > 0) {
b->grounding.subthreshold_hits++; subthreshold++;
} else if (incident == 0) { isolated++; }
else { starved++; }
/* Mirror the derived standing onto confidence so downstream reads
* (activate epistemic_confidence, realizer calibration) never exceed the
* grounding. Faithful representation, single source of truth. */
double stand = gep_standing(&b->grounding, now);
b->confidence = stand;
b->updated_at = now;
int after = gep_band_rank(stand);
if (after > before) graduations++;
if (after < before) demotions++;
}
/* Heartbeat telemetry — the gep_* line, sibling to the hebb_* gauges. */
char buf[512];
snprintf(buf, sizeof buf,
"{\"gep_processed\":%d,\"gep_strengthened\":%d,\"gep_decayed\":%d,"
"\"gep_subthreshold\":%d,\"gep_graduations\":%d,\"gep_demotions\":%d,"
"\"gep_isolated\":%d,\"gep_starved\":%d}",
processed, strengthened, decayed, subthreshold,
graduations, demotions, isolated, starved);
return EL_STR(el_strdup(buf));
}
@@ -1,299 +0,0 @@
/* ─────────────────────────────────────────────────────────────────────────
* gep_core.h — Grounded Edge-Propagation, the core mechanism (task #50).
*
* Edge-aware, dream-coupled consolidation. Runs DURING the consolidation/dream
* beat (awareness.el hebb_consolidate → engram_ground_propagate). Grounding
* propagates + strengthens/decays along edges, threshold-gated by CONVERGENT
* INDEPENDENT corroboration from adjacent grounded nodes.
*
* This header is the single source of truth for the algorithm. It is pure C
* (libm only — own-the-core, no new libraries) and operates on a compact graph
* view (GepGraph) that both the proof harness and the runtime native populate
* from the live EngramStore (nodes/edges flat arrays + adj_from/adj_to).
*
* SPEC (Will, 2026-08-15; memory 9e09a59f, refines 1a861007):
* - A grounding is a VECTOR + its HEBBIAN WEIGHTS — a weighted structure over
* the evidential neighborhood, NOT a scalar and NOT a flat list. It APPENDS
* and GROWS on SIGNIFICANT change. => grounding = an APPEND-ONLY event ring
* (GepGrounding), parallel to the ACT-R base-level access_ts ring already in
* EngramNode. Current standing is DERIVED, recency-weighted, never stored.
* - UPDATE = LTP/LTD with a THRESHOLD (the key nonlinearity). Sub-threshold =
* recorded in history but TRANSIENT (no lasting shift). Cross the threshold
* of convergent support → grounding STRENGTHENS. Contradiction/erosion past
* threshold → grounding DECAYS. Automatic, event-driven, salience-gated.
* - DRIVER = CONVERGENT INDEPENDENT CORROBORATION (coherentism, mechanized):
* when N INDEPENDENT adjacent nodes ground as likely-true around a
* conjecture (Will's example: 13), its grounding grows on its own.
* - INDEPENDENCE is load-bearing: N DISTINCT corroborators, not one node
* echoed N times. Guards against circular self-reinforcement.
* - ANTI-DELUSION GRAVITY (memory 0b15017c): support flows only FROM already-
* grounded neighbors. A belief cannot ground from ungrounded speculation,
* however self-consistent — nothing tethers it to the grounded core.
* - NOTHING IS SETTLED (memory 271f1163): grounded is strongly-held, still
* falsifiable. Decay path stays open on every node; history is append-only,
* supersede-not-delete.
* ───────────────────────────────────────────────────────────────────────── */
#ifndef GEP_CORE_H
#define GEP_CORE_H
#include <stdint.h>
#include <math.h>
#include <string.h>
/* ── Constants ──────────────────────────────────────────────────────────────
* GEP_DECAY_D matches ENGRAM_BLL_D (0.5, canonical ACT-R): the derived standing
* is recency-weighted over the grounding-event collection exactly as the
* base-level term is recency-weighted over the access ring (memory 1a861007:
* "structurally the ACT-R base-level pattern, a sum over time-stamped events").
*/
#define GEP_DECAY_D 0.5 /* ACT-R power-law recency exponent */
#define GEP_BASE 0.10 /* standing floor of a bare conjecture */
#define GEP_LIKELY_MIN 0.34 /* band: conjecture < LIKELY ≤ likely */
#define GEP_GROUNDED_MIN 0.66 /* band: likely < GROUNDED ≤ grounded */
#define GEP_N_MIN 3 /* min INDEPENDENT corroborators to cross */
#define GEP_THETA 0.30 /* min convergent-support MASS to cross */
#define GEP_MAG_GAIN 1.0 /* net-support → event-magnitude gain (tanh) */
#define GEP_EVENT_RING 32 /* grounding-history depth kept exactly */
/* A single grounding event — one contact with the evidential neighborhood.
* Append-only; the ring is the collection-over-time, the standing is derived. */
typedef struct {
int64_t ts; /* wall-clock ms of the grounding event */
int8_t sign; /* +1 = LTP (strengthen), -1 = LTD (decay) */
double mag; /* magnitude in (0,1], = tanh(gain·|net independent support|)*/
uint64_t sig; /* signature of the independent corroborator set (audit) */
} GepEvent;
/* The grounding of one node: an append-only ring of events + transient counters.
* older_count keeps the tail (events aged out of the ring) so the collection is
* never silently lost — supersede-not-delete. subthreshold_hits records beats
* where support was present but did NOT cross threshold (transient, no shift). */
typedef struct {
GepEvent ev[GEP_EVENT_RING];
int head; /* next write slot */
int filled; /* valid entries (≤ GEP_EVENT_RING) */
int64_t older_count; /* durable events aged past the ring */
int subthreshold_hits; /* transient sub-threshold beats, no shift */
} GepGrounding;
typedef struct {
const char* id;
GepGrounding gr;
int is_belief; /* 1 = subject to propagation (conjecture/belief) */
} GepNode;
/* An edge carries a HEBBIAN WEIGHT (EngramEdge.weight) and a polarity derived
* from its relation: supportive (supports/corroborates/derived-from/hebbian-
* associate) = +1, contradictory (contradicts/refutes) = -1. */
typedef struct {
int from; /* node index */
int to; /* node index */
double weight; /* Hebbian edge weight, [0,1] */
int8_t polarity; /* +1 supportive, -1 contradictory */
} GepEdge;
typedef struct {
GepNode* nodes; int n_nodes;
GepEdge* edges; int n_edges;
} GepGraph;
typedef struct {
int strengthened; /* beliefs that took an LTP event this beat */
int decayed; /* beliefs that took an LTD event this beat */
int subthreshold; /* beliefs with support present but below threshold */
int graduations; /* band-up transitions (conjecture→likely→grounded) */
int demotions; /* band-down transitions */
int isolated; /* belief nodes with ZERO incident edges (sparse graph) */
int starved; /* belief nodes with edges but NO grounded corroborator */
} GepBeatStats;
/* Real-graph note (live measurement 2026-08-15): 70.7% of nodes are isolated,
* connected core ~28%. Grounded edge-propagation is definitionally scoped to
* the connected core — a belief with no grounded neighbor has nothing to
* tether to (anti-delusion gravity). isolated/starved are surfaced as an
* interoceptive signal for the edge-formation / embedding pass (#20) to try to
* connect them; #50 CONSUMES edges, it does not form them. */
/* ── Standing derivation: collection → scalar, recency-weighted ─────────────
* standing = clamp( GEP_BASE + Σ_events sign·mag·age^(-D) , 0, 1 ).
* Exactly the ACT-R base-level shape (Σ t^-d) but sign-carrying so LTD subtracts.
* The value is a pure function of wall-clock time — idempotent, never stored. */
static inline double gep_standing(const GepGrounding* g, int64_t now_ms) {
double raw = 0.0;
for (int i = 0; i < g->filled; i++) {
double age = (double)(now_ms - g->ev[i].ts) / 1000.0;
if (age < 1.0) age = 1.0; /* clock-skew / same-beat → 1s */
raw += (double)g->ev[i].sign * g->ev[i].mag * pow(age, -GEP_DECAY_D);
}
double s = GEP_BASE + raw;
if (s < 0.0) s = 0.0;
if (s > 1.0) s = 1.0;
return s;
}
/* Band label from a standing value. */
static inline const char* gep_band(double standing) {
if (standing >= GEP_GROUNDED_MIN) return "grounded";
if (standing >= GEP_LIKELY_MIN) return "likely";
return "conjecture";
}
static inline int gep_band_rank(double standing) {
if (standing >= GEP_GROUNDED_MIN) return 2;
if (standing >= GEP_LIKELY_MIN) return 1;
return 0;
}
/* Append one grounding event to the ring (append-only; oldest slot recycles,
* its loss counted in older_count so the collection's depth is never faked). */
static inline void gep_append(GepGrounding* g, int64_t ts, int8_t sign,
double mag, uint64_t sig) {
if (g->filled >= GEP_EVENT_RING) g->older_count++;
g->ev[g->head].ts = ts;
g->ev[g->head].sign = sign;
g->ev[g->head].mag = mag;
g->ev[g->head].sig = sig;
g->head = (g->head + 1) % GEP_EVENT_RING;
if (g->filled < GEP_EVENT_RING) g->filled++;
}
/* ── Independence via union-find over corroborators ─────────────────────────
* Two corroborators are the SAME independent source if they are the same node,
* or if a direct edge links them (mutually-derived / echoed through a chain).
* Counting DISTINCT components — not raw corroborator count — is the guard
* against one node echoed N times reading as N independent corroborations. */
#define GEP_MAX_CORR 256
typedef struct {
int node_idx[GEP_MAX_CORR]; /* corroborator node index */
double contrib[GEP_MAX_CORR]; /* weight·standing(c) */
int parent[GEP_MAX_CORR]; /* union-find parent */
int n;
} GepCorrSet;
static int gep_uf_find(GepCorrSet* s, int x) {
while (s->parent[x] != x) { s->parent[x] = s->parent[s->parent[x]]; x = s->parent[x]; }
return x;
}
static void gep_uf_union(GepCorrSet* s, int a, int b) {
int ra = gep_uf_find(s, a), rb = gep_uf_find(s, b);
if (ra != rb) s->parent[ra] = rb;
}
/* index of node_idx within the corroborator set, or -1 */
static int gep_corr_index_of(const GepCorrSet* s, int node_idx) {
for (int i = 0; i < s->n; i++) if (s->node_idx[i] == node_idx) return i;
return -1;
}
/* ── The beat: grounded edge-propagation over one belief node ───────────────
* Returns +1 if an LTP event was appended, -1 if LTD, 0 if sub-threshold/none.
* out_pos/out_neg/out_np/out_nn expose the raw support decomposition for the
* proof ledger (mass and independent-component counts on each polarity). */
static int gep_propagate_node(GepGraph* g, int b, int64_t now_ms,
double* out_pos, double* out_neg,
int* out_np, int* out_nn, int* out_incident) {
GepCorrSet cs; cs.n = 0;
int incident = 0; /* any edge touching b at all — isolation detector */
/* 1. Gather corroborators along incident edges. Anti-delusion gravity:
* only ALREADY-grounded neighbors (standing ≥ LIKELY_MIN) may corroborate.
* Each contributes weight·standing; polarity kept via signed contrib.
* 1-HOP ONLY — no BFS fan-out, so no per-hop breadth explosion. The
* corroborator working set is hard-capped at GEP_MAX_CORR (beam bound
* against hub belief nodes with thousands of incident edges). */
for (int e = 0; e < g->n_edges; e++) {
int c = -1; int8_t pol = 0;
if (g->edges[e].from == b) { c = g->edges[e].to; pol = g->edges[e].polarity; }
else if (g->edges[e].to == b) { c = g->edges[e].from; pol = g->edges[e].polarity; }
else continue;
incident++;
if (c < 0 || c == b) continue;
double cs_standing = gep_standing(&g->nodes[c].gr, now_ms);
if (cs_standing < GEP_LIKELY_MIN) continue; /* ungrounded ⇒ no pull */
double contribution = g->edges[e].weight * cs_standing * (double)pol;
int existing = gep_corr_index_of(&cs, c);
if (existing >= 0) {
/* same corroborator id reached twice (multi-edge echo): keep the
* strongest-magnitude contribution, do NOT add — one source, one vote */
if (fabs(contribution) > fabs(cs.contrib[existing]))
cs.contrib[existing] = contribution;
} else if (cs.n < GEP_MAX_CORR) { /* beam bound against hub belief nodes */
cs.node_idx[cs.n] = c;
cs.contrib[cs.n] = contribution;
cs.parent[cs.n] = cs.n;
cs.n++;
}
}
if (out_incident) *out_incident = incident;
/* 2. Collapse mutually-derived corroborators (an edge between two of them =
* echo chain / shared derivation) into one independent component. */
for (int e = 0; e < g->n_edges; e++) {
int ia = gep_corr_index_of(&cs, g->edges[e].from);
int ib = gep_corr_index_of(&cs, g->edges[e].to);
if (ia >= 0 && ib >= 0) gep_uf_union(&cs, ia, ib);
}
/* 3. Per independent component, take the MAX-magnitude member (echoes don't
* inflate mass either), split by polarity. Convergent INDEPENDENT support
* = sum over components; independence count = number of components. */
double comp_best[GEP_MAX_CORR];
int comp_root[GEP_MAX_CORR]; int n_comp = 0;
for (int i = 0; i < cs.n; i++) {
int r = gep_uf_find(&cs, i);
int slot = -1;
for (int k = 0; k < n_comp; k++) if (comp_root[k] == r) { slot = k; break; }
if (slot < 0) { slot = n_comp++; comp_root[slot] = r; comp_best[slot] = cs.contrib[i]; }
else if (fabs(cs.contrib[i]) > fabs(comp_best[slot])) comp_best[slot] = cs.contrib[i];
}
double pos = 0.0, neg = 0.0; int np = 0, nn = 0;
uint64_t sig = 1469598103934665603ULL; /* FNV offset — signature of the set */
for (int k = 0; k < n_comp; k++) {
if (comp_best[k] > 0.0) { pos += comp_best[k]; np++; }
else if (comp_best[k] < 0.0) { neg += -comp_best[k]; nn++; }
sig = (sig ^ (uint64_t)comp_root[k]) * 1099511628211ULL;
}
if (out_pos) *out_pos = pos; if (out_neg) *out_neg = neg;
if (out_np) *out_np = np; if (out_nn) *out_nn = nn;
double net = pos - neg;
/* 4. Threshold gate. Convergent independent corroboration must clear BOTH a
* MASS threshold (THETA) and an INDEPENDENCE-count threshold (N_MIN).
* The count gate is the independence guard: echoed support collapses to
* one component and never reaches N_MIN however large the raw fan-in. */
if (net > 0.0 && pos >= GEP_THETA && np >= GEP_N_MIN) {
double mag = tanh(GEP_MAG_GAIN * net);
gep_append(&g->nodes[b].gr, now_ms, +1, mag, sig);
return +1;
}
if (net < 0.0 && neg >= GEP_THETA && nn >= GEP_N_MIN) {
double mag = tanh(GEP_MAG_GAIN * (-net));
gep_append(&g->nodes[b].gr, now_ms, -1, mag, sig);
return -1;
}
/* Sub-threshold: support seen but did not cross. Recorded, transient, no
* lasting shift — exactly Will's "recorded in history but transient". */
if (np > 0 || nn > 0) g->nodes[b].gr.subthreshold_hits++;
return 0;
}
/* Run one consolidation/dream beat over every belief node in the graph. */
static inline GepBeatStats gep_beat(GepGraph* g, int64_t now_ms) {
GepBeatStats st; memset(&st, 0, sizeof st);
for (int b = 0; b < g->n_nodes; b++) {
if (!g->nodes[b].is_belief) continue;
int before = gep_band_rank(gep_standing(&g->nodes[b].gr, now_ms));
double pos, neg; int np, nn, incident;
int r = gep_propagate_node(g, b, now_ms, &pos, &neg, &np, &nn, &incident);
int after = gep_band_rank(gep_standing(&g->nodes[b].gr, now_ms));
if (r > 0) st.strengthened++;
else if (r < 0) st.decayed++;
else if (np > 0 || nn > 0) st.subthreshold++;
else if (incident == 0) st.isolated++; /* sparse-graph reality */
else st.starved++; /* has edges, no grounded neighbor */
if (after > before) st.graduations++;
if (after < before) st.demotions++;
}
return st;
}
#endif /* GEP_CORE_H */
@@ -1,232 +0,0 @@
/* ─────────────────────────────────────────────────────────────────────────
* gep_proof.c — PROOF LEDGER for grounded edge-propagation (task #50).
*
* Self-contained. Builds three scenarios on an in-memory GepGraph that mirrors
* the live EngramStore's flat node/edge arrays, runs the consolidation/dream
* beat (gep_beat), and prints RAW grounding before/after for each:
*
* (A) STRENGTHEN — a conjecture + N independent grounded corroborators.
* Grounding grows past threshold, GRADUATES conjecture→
* likely→grounded, then RELAXES when corroboration stops
* (nothing is settled).
* (B) DECAY — a grounded belief meets N independent CONTRADICTORY
* corroborators. Grounding decays grounded→likely→conjecture.
* (C) INDEPENDENCE GUARD — identical fan-in of N=5, weights, and standings.
* C1: 5 DISTINCT independent corroborators → grounds.
* C2: the SAME support echoed (5 mutually-linked / one node
* repeated) → collapses to 1 independent → does NOT.
*
* Build: cc -std=c11 -O2 -o gep_proof gep_proof.c -lm
* Run: ./gep_proof
* ───────────────────────────────────────────────────────────────────────── */
#include <stdio.h>
#include <stdlib.h>
#include "gep_core.h"
#define T0 1786000000000LL /* fixed base time (ms) — deterministic */
#define BEAT_MS 60000LL /* 60s heartbeat cadence (awareness.el) */
/* Seed a node's grounding with a prior LTP event so it reads as already-grounded
* (a member of the grounded core that gravity radiates from). mag→standing:
* standing = GEP_BASE + mag (event at ~now). */
static void seed_grounded(GepNode* n, double mag, int64_t ts) {
memset(&n->gr, 0, sizeof n->gr);
gep_append(&n->gr, ts, +1, mag, 0);
}
/* Re-anchor every NON-belief node (the corroborators/refuters) as a freshly-
* grounded member of the core AT time `now`. These nodes are, by definition,
* sustained members of the grounded core — each has its OWN ongoing
* corroboration — so their standing must be read as grounded at each beat, not
* left to power-law-decay out of the core between beats. The belief-under-test
* is NEVER re-anchored: its trajectory is driven only by the propagation. */
static void anchor_core(GepGraph* g, int64_t now, double mag) {
for (int i = 0; i < g->n_nodes; i++)
if (!g->nodes[i].is_belief) seed_grounded(&g->nodes[i], mag, now);
}
static void print_node(const char* tag, GepNode* n, int64_t now) {
double s = gep_standing(&n->gr, now);
printf(" %-14s standing=%.4f band=%-10s events=%d subthresh=%d\n",
tag, s, gep_band(s), n->gr.filled, n->gr.subthreshold_hits);
}
/* Run one beat over a single belief node b and print the raw support decomposition. */
static void beat_and_report(GepGraph* g, int b, int64_t now, int beatno,
const char* note) {
anchor_core(g, now, 0.80); /* corroborators stay grounded at each beat */
double s_before = gep_standing(&g->nodes[b].gr, now);
int r_before = gep_band_rank(s_before);
double pos, neg; int np, nn, incident;
int r = gep_propagate_node(g, b, now, &pos, &neg, &np, &nn, &incident);
double s_after = gep_standing(&g->nodes[b].gr, now);
int r_after = gep_band_rank(s_after);
const char* action = (r > 0) ? "LTP (strengthen)"
: (r < 0) ? "LTD (decay)"
: (np || nn) ? "sub-threshold (no shift)"
: (incident == 0) ? "isolated (no edges)"
: "starved (no grounded neighbor)";
printf(" beat %d (t=+%llds) %s\n", beatno,
(long long)((now - T0) / 1000), note ? note : "");
printf(" incident_edges=%d pos_mass=%.4f (n_indep=%d) neg_mass=%.4f (n_indep=%d)"
" THETA=%.2f N_MIN=%d\n",
incident, pos, np, neg, nn, (double)GEP_THETA, GEP_N_MIN);
printf(" -> %-26s standing %.4f (%s) -> %.4f (%s)%s\n",
action, s_before, gep_band(s_before), s_after, gep_band(s_after),
(r_after > r_before) ? " [GRADUATED]"
: (r_after < r_before) ? " [DEMOTED]" : "");
}
/* ── Scenario A — STRENGTHEN + graduation + relaxation ───────────────────── */
static void scenario_A(void) {
printf("\n=== SCENARIO A — STRENGTHEN: convergent independent corroboration ===\n");
/* nodes[0] = the conjecture (belief). nodes[1..8] = independent corroborators,
* each already grounded, each tethered to the conjecture by a weak young
* hebbian-associate edge (weight 0.15 = ENGRAM_HEBB_LINK_W0). The corroborators
* are NOT linked to each other → fully independent. */
static GepNode nodes[9];
static GepEdge edges[8];
memset(nodes, 0, sizeof nodes);
nodes[0].id = "conjecture"; nodes[0].is_belief = 1; /* bare: standing = BASE */
for (int i = 1; i <= 8; i++) {
nodes[i].id = "corroborator";
seed_grounded(&nodes[i], 0.80, T0); /* standing ≈ 0.90 → grounded core */
}
GepGraph g = { nodes, 9, edges, 0 };
printf(" seed: conjecture has NO grounding events; corroborators pre-grounded.\n");
print_node("conjecture", &nodes[0], T0);
/* Beat 1: 3 independent corroborators have grounded up around the conjecture. */
g.n_edges = 0;
for (int i = 1; i <= 3; i++)
edges[g.n_edges++] = (GepEdge){ 0, i, 0.15, +1 };
beat_and_report(&g, 0, T0, 1, "3 independent grounded corroborators appear");
/* Beat 2: the neighborhood fills in — 5 independent corroborators now. */
g.n_edges = 0;
for (int i = 1; i <= 5; i++)
edges[g.n_edges++] = (GepEdge){ 0, i, 0.15, +1 };
beat_and_report(&g, 0, T0 + BEAT_MS, 2, "neighborhood grows to 5 corroborators");
/* Beat 3: support sustained at 5 (grounding refreshed). */
beat_and_report(&g, 0, T0 + 2 * BEAT_MS, 3, "support sustained (5)");
/* Beats 4-6: corroboration REMOVED (neighbors superseded / no longer ground).
* No new events; the collection ages → standing relaxes. Nothing is settled. */
g.n_edges = 0;
beat_and_report(&g, 0, T0 + 12 * BEAT_MS, 4, "corroboration withdrawn (+10min)");
beat_and_report(&g, 0, T0 + 60 * BEAT_MS, 5, "still withdrawn (+1h)");
beat_and_report(&g, 0, T0 + 240 * BEAT_MS, 6, "still withdrawn (+4h)");
printf(" RESULT: grounding grew automatically past threshold and graduated,\n"
" then relaxed once the independent support stopped — living,\n"
" not a latched flag.\n");
}
/* ── Scenario B — DECAY via accreting contradiction ─────────────────────── */
static void scenario_B(void) {
printf("\n=== SCENARIO B — DECAY: convergent independent CONTRADICTION ===\n");
static GepNode nodes[6];
static GepEdge edges[5];
memset(nodes, 0, sizeof nodes);
nodes[0].id = "belief"; nodes[0].is_belief = 1;
/* Seed the belief as already GROUNDED via a strong prior LTP event. */
seed_grounded(&nodes[0], 0.85, T0);
for (int i = 1; i <= 5; i++) {
nodes[i].id = "refuter";
seed_grounded(&nodes[i], 0.80, T0); /* grounded contradictors */
}
GepGraph g = { nodes, 6, edges, 0 };
printf(" seed: belief pre-grounded by a strong prior LTP event.\n");
print_node("belief", &nodes[0], T0);
/* Contradiction accretes over successive beats: 3 then 5 independent grounded
* refuters (polarity -1). Each beat past threshold appends an LTD event.
* Beat 1 runs at the seed instant so the trajectory starts from grounded. */
g.n_edges = 0;
for (int i = 1; i <= 3; i++) edges[g.n_edges++] = (GepEdge){ 0, i, 0.20, -1 };
beat_and_report(&g, 0, T0, 1, "3 independent contradictions");
g.n_edges = 0;
for (int i = 1; i <= 5; i++) edges[g.n_edges++] = (GepEdge){ 0, i, 0.20, -1 };
beat_and_report(&g, 0, T0 + BEAT_MS, 2, "contradiction broadens to 5");
beat_and_report(&g, 0, T0 + 2 * BEAT_MS, 3, "contradiction sustained (5)");
beat_and_report(&g, 0, T0 + 3 * BEAT_MS, 4, "contradiction sustained (5)");
printf(" RESULT: grounding decayed grounded->likely->conjecture under\n"
" convergent independent contradiction. The door never shut\n"
" on the belief; its history is retained (events keep growing).\n");
}
/* ── Scenario C — INDEPENDENCE GUARD ─────────────────────────────────────── */
static void scenario_C(void) {
printf("\n=== SCENARIO C — INDEPENDENCE GUARD (the load-bearing property) ===\n");
printf(" Both sub-cases: N=5 corroborators, edge weight 0.30, corroborator\n"
" standing ~0.90. ONLY difference: whether the 5 are independent.\n");
/* C1 — 5 DISTINCT INDEPENDENT corroborators (no edges among them). */
{
printf("\n -- C1: 5 DISTINCT independent corroborators --\n");
static GepNode nodes[6];
static GepEdge edges[5];
memset(nodes, 0, sizeof nodes);
nodes[0].id = "conjecture"; nodes[0].is_belief = 1;
for (int i = 1; i <= 5; i++) { nodes[i].id = "corr"; seed_grounded(&nodes[i], 0.80, T0); }
for (int i = 1; i <= 5; i++) edges[i-1] = (GepEdge){ 0, i, 0.30, +1 };
GepGraph g = { nodes, 6, edges, 5 };
print_node("conjecture", &nodes[0], T0);
beat_and_report(&g, 0, T0, 1, "5 independent corroborators (no inter-links)");
}
/* C2 — the SAME support echoed: 5 corroborators that are all mutually linked
* (a derivation clique — one source echoed through the chain). Same fan-in to
* the conjecture, same weights, same standings. Union-find collapses them to
* ONE independent component → below N_MIN → NO strengthening. */
{
printf("\n -- C2: 5 corroborators, but mutually-linked (echo of ONE source) --\n");
static GepNode nodes[6];
static GepEdge edges[9]; /* 5 to conjecture + 4 chaining corr1..corr5 */
memset(nodes, 0, sizeof nodes);
nodes[0].id = "conjecture"; nodes[0].is_belief = 1;
for (int i = 1; i <= 5; i++) { nodes[i].id = "corr"; seed_grounded(&nodes[i], 0.80, T0); }
int ne = 0;
for (int i = 1; i <= 5; i++) edges[ne++] = (GepEdge){ 0, i, 0.30, +1 };
/* chain corr1-corr2-corr3-corr4-corr5: they are the same source echoed */
for (int i = 1; i <= 4; i++) edges[ne++] = (GepEdge){ i, i+1, 0.30, +1 };
GepGraph g = { nodes, 6, edges, ne };
print_node("conjecture", &nodes[0], T0);
beat_and_report(&g, 0, T0, 1, "5 echoed (mutually-linked) corroborators");
}
/* C3 — degenerate echo: literally ONE corroborator reached by 5 parallel edges. */
{
printf("\n -- C3: ONE corroborator, reached by 5 parallel edges --\n");
static GepNode nodes[2];
static GepEdge edges[5];
memset(nodes, 0, sizeof nodes);
nodes[0].id = "conjecture"; nodes[0].is_belief = 1;
nodes[1].id = "corr"; seed_grounded(&nodes[1], 0.80, T0);
for (int i = 0; i < 5; i++) edges[i] = (GepEdge){ 0, 1, 0.30, +1 };
GepGraph g = { nodes, 2, edges, 5 };
print_node("conjecture", &nodes[0], T0);
beat_and_report(&g, 0, T0, 1, "same node, 5 parallel edges");
}
printf("\n RESULT: identical raw fan-in (5) and mass inputs; C1 grounds because\n"
" the corroboration is INDEPENDENT (5 components), C2/C3 do not\n"
" because it collapses to ONE source. Circular self-reinforcement\n"
" cannot manufacture grounding.\n");
}
int main(void) {
printf("GROUNDED EDGE-PROPAGATION — PROOF LEDGER (task #50)\n");
printf("constants: BASE=%.2f LIKELY_MIN=%.2f GROUNDED_MIN=%.2f "
"N_MIN=%d THETA=%.2f D=%.1f\n",
(double)GEP_BASE, (double)GEP_LIKELY_MIN, (double)GEP_GROUNDED_MIN,
GEP_N_MIN, (double)GEP_THETA, (double)GEP_DECAY_D);
scenario_A();
scenario_B();
scenario_C();
printf("\nDONE.\n");
return 0;
}
@@ -1,29 +0,0 @@
//
// server.route.patch.el GATED route for task #50, for engram/src/server.el.
// NOT APPLIED. Exposes the engram_ground_propagate native over HTTP so the
// soul's consolidation beat can fire one grounded edge-propagation pass.
//
// [1] New handler add beside route_strengthen (server.el ~line 194).
// Mutation (appends grounding events, updates confidence), so it is gated
// on _auth via check_auth_ok, exactly like /api/edges. Persists once after
// the beat the whole point of running propagation as one batched beat
// rather than per-node is to pay the snapshot cost a single time.
fn route_ground_propagate(method: String, path: String, body: String) -> String {
if !check_auth_ok(method, body) { return err_json("unauthorized") }
let tel: String = engram_ground_propagate() // native one beat over the store
let saved: Int = persist_canonical()
return tel // gep_* telemetry JSON straight through
}
// [2] Dispatch register in handle_request (server.el ~line 461, next to the
// /api/strengthen arm):
//
// if str_eq(method, "POST") && (str_eq(clean, "/api/ground/propagate")) {
// return route_ground_propagate(method, clean, body)
// }
//
// [3] Native declaration engram_ground_propagate must be declared as an
// extern runtime builtin (el_runtime.h) and seed-wrapped (el_seed.c /
// el_seed.h __engram_ground_propagate) so the EL side can call it, same as
// engram_strengthen / engram_hebb_drain_json.
+8
View File
@@ -0,0 +1,8 @@
# Build + runtime artifacts — never committed.
bin/
# Captured media (camera frames, mic audio) and syntheses. Raw streams stay
# LOCAL and never egress — including into git.
out/
# Runtime consent + resume state (local, per-machine).
.consent.json
.resume.json
+80
View File
@@ -0,0 +1,80 @@
# peripheral — Neuron's I/O organ (own-core, local, consent-gated)
The interface made physical. Two afferent senses in, one efferent voice out —
all reached the way the agentic surface reaches any tool.
```
MIC (hear) afferent device -> capture -> descriptor -> ingest -> geometry
CAMERA (see) afferent device -> capture -> descriptor -> ingest -> scene-geometry
SPEAKER(speak) efferent render WAV -> PLAY ALOUD out the speaker
```
Closes the conversational loop: **hear (mic) -> understand (engram) -> speak (speaker)**.
## Rails
- **Own-core.** macOS-native only: AVFoundation (camera/mic), CoreAudio voice-
processing (AEC), afplay (speaker), ImageIO/CoreGraphics (frames), hand-rolled
DSP (WAV, LPC, formant synthesis). No cloud, no heavy deps.
- **Local-only.** Raw streams are written to `out/` and never egress. `.gitignore`
keeps captured media out of git.
- **Consent-gated (two locks).** A Neuron-level grant (`grant`/`revoke`) *and* the
OS TCC permission. Sensitive senses (camera/mic) fail closed without both.
- **Disclosed.** Every device touch prints a `[peripheral]` line on stderr.
## Build
```
swiftc -O -o bin/periph src/periph.swift \
-framework AVFoundation -framework CoreMedia -framework Foundation \
-framework CoreGraphics -framework ImageIO -framework CoreImage
```
## Commands
```
periph grant|revoke <camera|mic> # Neuron-level consent
periph status
periph speak <file.wav> # SPEAK ALOUD (efferent)
periph tone <out.wav> [hz] [sec] # own-core WAV synth
periph listen <sec> <out.wav> # MIC capture (afferent), 16k mono
periph see <out.jpg> # CAMERA one frame (afferent)
periph feat-audio <wav> | feat-image <jpg> # capture -> compact descriptor
periph ingest-audio|ingest-image <file> <engramURL> # descriptor -> engram node (geometry)
periph voiceprint <voice.wav> # extract F0 + formants F1-F5
periph imitate <voice.wav> <out.wav> # speak back in that voice (LPC resynthesis)
periph hear-imitate <sec> <out.wav> # MIC -> signature -> imitate -> SPEAK ALOUD
periph converse <manifest.json> [--authority F] [--barge-at S[:backchannel|:bargein]] [--resume] [--live-mic]
```
## The afferent metabolism
A capture is never shipped raw. It becomes a **compact descriptor** — the afferent
twin of the music instrument-signature:
- audio -> `[seconds, sr, ch, rms, peak, zcr, centroid, F0]` (~2400-6000x smaller)
- image -> `[w, h, meanRGB, brightness, 3x3 luminance grid]` (~400000x smaller)
- voice -> `[F0, F1..F5, bandwidths]` (11 numbers)
That descriptor is what the ingest organ (engram `POST /api/nodes`) turns into an
embedded node = geometry.
## Voice by imitation
`voiceprint`/`imitate` are own-core LPC (autocorrelation + Levinson-Durbin, order
16 @ 16 kHz), formant extraction from the LPC spectral envelope, and source-filter
resynthesis (glottal impulse train at F0 through the all-pole formant filter). A
voice is grabbed by ear as ~a dozen numbers and spoken back — **no training, no
stolen voice.** Measured fidelity on real speech: resynthesized formants match the
source within 2-3%. The full phoneme->formant path for *novel* sentences is the
speech faculty's seam (`elp` audio surface profile); this engine provides the
formant synthesis primitive it renders through.
## Interruptibility (native turn-taking)
`converse` plays the utterance as an ordered, salience-tagged **meaning-plan**
while the mic listens (full-duplex, AEC on so it never barges in on its own voice):
- **barge-in**: user speech -> pause on the spot (sample-accurate), not "finish the buffer."
- **yield-or-hold**: a decision grounded in the current segment's salience + progress
+ the interrupter's authority — YIELD (stop) or HOLD ("hang on, let me finish").
- **backchannel** ("mm-hm"): brief/low -> keep going, resume seamlessly.
- **resumable**: on yield the remaining plan persists (`.resume.json`); `--resume`
picks the thread back up ("as I was saying").
Live full-duplex uses `--live-mic` (OS AEC). Injected `--barge-at` drives the
decision loop deterministically for testing.
```
```
+939
View File
@@ -0,0 +1,939 @@
// periph.swift Neuron's PERIPHERAL I/O organ (own-core, LOCAL, CONSENT-GATED).
//
// The interface made physical:
// MIC (hear) = afferent : device -> capture -> [ingest -> geometry]
// CAMERA (see) = afferent : device -> capture -> [ingest -> scene-geometry]
// SPEAKER(speak) = efferent : [render WAV] -> PLAY ALOUD out the speaker
//
// Rails: own-core (AVFoundation / CoreAudio / afplay all ship with macOS),
// no cloud, no heavy deps, raw streams stay LOCAL and never egress,
// every device access is CONSENT-GATED and DISCLOSED.
//
// Full-duplex CONVERSE mode implements native interruptibility: while the
// speaker plays the utterance (a persistent, segmented meaning-plan), the mic
// listens; on user speech it interrupts instantly, then DECIDES yield-or-hold
// grounded in the salience of what it is mid-saying, and can RESUME the thread.
//
// Build: swiftc -O -o peripheral/bin/periph peripheral/src/periph.swift \
// -framework AVFoundation -framework CoreMedia -framework Foundation
import Foundation
import AVFoundation
import CoreMedia
import CoreGraphics
import ImageIO
import CoreImage
// ----------------------------------------------------------------------------
// Disclosure every peripheral touch is announced on stderr. Nothing is silent.
// ----------------------------------------------------------------------------
func disclose(_ msg: String) {
FileHandle.standardError.write(" [peripheral] \(msg)\n".data(using: .utf8)!)
}
func emit(_ obj: [String: Any]) { // machine-readable event on stdout (JSON line)
if let d = try? JSONSerialization.data(withJSONObject: obj),
let s = String(data: d, encoding: .utf8) {
print(s)
}
}
func die(_ msg: String) -> Never {
disclose("ERROR: \(msg)")
emit(["ok": false, "error": msg])
exit(1)
}
// ----------------------------------------------------------------------------
// Consent store Neuron's OWN gate, on top of the OS (TCC) gate. Two locks on
// the sensitive senses. Persisted locally next to the binary's organ dir.
// ----------------------------------------------------------------------------
struct Consent {
static let path: String = {
let dir = ProcessInfo.processInfo.environment["PERIPH_HOME"]
?? FileManager.default.currentDirectoryPath + "/peripheral"
return dir + "/.consent.json"
}()
static func load() -> [String: Bool] {
guard let d = FileManager.default.contents(atPath: path),
let o = try? JSONSerialization.jsonObject(with: d) as? [String: Bool]
else { return ["camera": false, "mic": false] }
return o
}
static func save(_ g: [String: Bool]) {
let d = try! JSONSerialization.data(withJSONObject: g, options: [.prettyPrinted])
try? d.write(to: URL(fileURLWithPath: path))
}
// Neuron-level gate. Sensitive senses (camera/mic) require an explicit grant.
static func require(_ device: String) {
let g = load()
if g[device] != true {
die("CONSENT DENIED for '\(device)'. The user has not granted this sense. " +
"Run: periph grant \(device) (raw streams stay local, never egress).")
}
disclose("consent OK (Neuron-level) for '\(device)' — local only, never egresses.")
}
}
// ----------------------------------------------------------------------------
// OS (TCC) permission the second lock. AVFoundation prompts the user the first
// time; if denied, we fail cleanly rather than hang.
// ----------------------------------------------------------------------------
func requireOSAccess(_ media: AVMediaType, _ label: String) {
let status = AVCaptureDevice.authorizationStatus(for: media)
switch status {
case .authorized:
disclose("consent OK (OS/TCC) for \(label).")
return
case .notDetermined:
disclose("requesting OS permission for \(label) (first use) — user must grant...")
let sem = DispatchSemaphore(value: 0)
var ok = false
AVCaptureDevice.requestAccess(for: media) { granted in ok = granted; sem.signal() }
_ = sem.wait(timeout: .now() + 30)
if !ok { die("OS permission for \(label) was not granted.") }
disclose("consent OK (OS/TCC) for \(label).")
case .denied, .restricted:
die("OS permission for \(label) is DENIED in System Settings > Privacy. " +
"Grant it to the controlling terminal/app, then retry.")
@unknown default:
die("unknown OS permission state for \(label).")
}
}
// ----------------------------------------------------------------------------
// Own-core WAV writer (16-bit PCM). No library proves we own the medium.
// ----------------------------------------------------------------------------
func writeWav(_ url: URL, samples: [Int16], sampleRate: Int, channels: Int = 1) {
var data = Data()
func u32(_ v: UInt32) { var x = v.littleEndian; data.append(Data(bytes: &x, count: 4)) }
func u16(_ v: UInt16) { var x = v.littleEndian; data.append(Data(bytes: &x, count: 2)) }
let bytesPerSample = 2
let dataBytes = samples.count * bytesPerSample
let byteRate = sampleRate * channels * bytesPerSample
data.append("RIFF".data(using: .ascii)!); u32(UInt32(36 + dataBytes))
data.append("WAVE".data(using: .ascii)!)
data.append("fmt ".data(using: .ascii)!); u32(16); u16(1); u16(UInt16(channels))
u32(UInt32(sampleRate)); u32(UInt32(byteRate))
u16(UInt16(channels * bytesPerSample)); u16(16)
data.append("data".data(using: .ascii)!); u32(UInt32(dataBytes))
for s in samples { var x = s.littleEndian; data.append(Data(bytes: &x, count: 2)) }
try? data.write(to: url)
}
// Read a WAV's basic geometry (own-core header parse). Walks chunks to find
// 'fmt ' and 'data' robust to JUNK/FLLR padding chunks (AVAudioRecorder emits them).
func wavInfo(_ path: String) -> (sampleRate: Int, channels: Int, bits: Int, frames: Int)? {
guard let d = FileManager.default.contents(atPath: path), d.count > 44 else { return nil }
func rd16(_ o: Int) -> Int { Int(d[o]) | (Int(d[o+1]) << 8) }
func rd32(_ o: Int) -> Int { Int(d[o]) | (Int(d[o+1])<<8) | (Int(d[o+2])<<16) | (Int(d[o+3])<<24) }
var channels = 0, sampleRate = 0, bits = 0, dataSize = 0
var o = 12
while o + 8 <= d.count {
let id = String(bytes: d[o..<o+4], encoding: .ascii) ?? ""
let sz = rd32(o+4)
if id == "fmt " && o + 24 <= d.count {
channels = rd16(o+10); sampleRate = rd32(o+12); bits = rd16(o+22)
} else if id == "data" {
dataSize = min(sz, d.count - (o+8))
}
o += 8 + sz + (sz & 1)
}
let frames = (channels > 0 && bits > 0) ? dataSize / (channels * bits/8) : 0
return (sampleRate, channels, bits, frames)
}
// ----------------------------------------------------------------------------
// SPEAKER (efferent) play a WAV ALOUD. Own-core: afplay ships with macOS.
// ----------------------------------------------------------------------------
func speak(_ wavPath: String) {
guard FileManager.default.fileExists(atPath: wavPath) else { die("no such file: \(wavPath)") }
disclose("SPEAKER: playing '\(wavPath)' ALOUD out the local speaker (efferent).")
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/afplay")
p.arguments = [wavPath]
try? p.run(); p.waitUntilExit()
let ok = p.terminationStatus == 0
disclose(ok ? "SPEAKER: done — Neuron spoke aloud." : "SPEAKER: afplay failed.")
if let i = wavInfo(wavPath) {
emit(["ok": ok, "op": "speak", "file": wavPath, "played_aloud": ok,
"sample_rate": i.sampleRate, "channels": i.channels,
"seconds": Double(i.frames)/Double(max(i.sampleRate,1))])
} else {
emit(["ok": ok, "op": "speak", "file": wavPath, "played_aloud": ok])
}
}
// ----------------------------------------------------------------------------
// MIC (afferent) capture N seconds -> 16k mono 16-bit WAV (formant-ready).
// ----------------------------------------------------------------------------
func listen(seconds: Double, out: String) {
Consent.require("mic")
requireOSAccess(.audio, "microphone")
disclose("MIC: capturing \(seconds)s -> '\(out)' (16 kHz mono, LOCAL, never egresses).")
let url = URL(fileURLWithPath: out)
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: 16000.0,
AVNumberOfChannelsKey: 1,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
]
guard let rec = try? AVAudioRecorder(url: url, settings: settings) else {
die("could not open the microphone recorder.")
}
rec.record()
Thread.sleep(forTimeInterval: seconds)
rec.stop()
// let the file flush
Thread.sleep(forTimeInterval: 0.1)
if let i = wavInfo(out) {
disclose("MIC: captured \(i.frames) frames @ \(i.sampleRate)Hz — ready to hand to the ingest organ.")
emit(["ok": true, "op": "listen", "file": out, "sample_rate": i.sampleRate,
"channels": i.channels, "frames": i.frames,
"seconds": Double(i.frames)/Double(max(i.sampleRate,1)),
"next": "ingest -> phonetic/voice geometry"])
} else {
die("mic capture produced no readable WAV.")
}
}
// ----------------------------------------------------------------------------
// CAMERA (afferent) capture ONE frame -> JPEG on disk.
// ----------------------------------------------------------------------------
// Grab one video frame via AVCaptureVideoDataOutput (CLI-safe; no KVO/photo classes).
final class FrameGrabber: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
let sem = DispatchSemaphore(value: 0)
var cgImage: CGImage?
var seen = 0
let cictx = CIContext(options: nil)
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
seen += 1
if cgImage != nil || seen < 5 { return } // let exposure settle a few frames
guard let pb = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let ci = CIImage(cvPixelBuffer: pb)
cgImage = cictx.createCGImage(ci, from: ci.extent)
sem.signal()
}
}
func see(out: String) {
Consent.require("camera")
requireOSAccess(.video, "camera")
disclose("CAMERA: capturing one frame -> '\(out)' (LOCAL, never egresses).")
let session = AVCaptureSession()
session.sessionPreset = .photo
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { die("no camera device available.") }
session.addInput(input)
let output = AVCaptureVideoDataOutput()
output.alwaysDiscardsLateVideoFrames = true
let grabber = FrameGrabber()
output.setSampleBufferDelegate(grabber, queue: DispatchQueue(label: "periph.cam"))
guard session.canAddOutput(output) else { die("cannot add video output.") }
session.addOutput(output)
session.startRunning()
if grabber.sem.wait(timeout: .now() + 10) == .timedOut { session.stopRunning(); die("camera capture timed out.") }
session.stopRunning()
guard let cg = grabber.cgImage,
let dst = CGImageDestinationCreateWithURL(URL(fileURLWithPath: out) as CFURL,
"public.jpeg" as CFString, 1, nil)
else { die("camera returned no frame.") }
CGImageDestinationAddImage(dst, cg, nil)
guard CGImageDestinationFinalize(dst) else { die("could not write JPEG.") }
let bytes = ((try? FileManager.default.attributesOfItem(atPath: out))?[.size] as? Int) ?? 0
disclose("CAMERA: wrote \(cg.width)x\(cg.height) frame (\(bytes) bytes) — ready for scene-geometry ingest.")
emit(["ok": true, "op": "see", "file": out, "width": cg.width, "height": cg.height,
"bytes": bytes, "next": "ingest -> scene-geometry"])
}
// ============================================================================
// FEAT the afferent METABOLISM: a raw capture becomes a COMPACT descriptor
// (a few dozen numbers), the mirror of the efferent signature. This is what
// gets handed to the ingest organ as geometry NOT the raw stream. Own-core.
// ============================================================================
// Read all 16-bit PCM samples from a WAV (own-core).
func readWavSamples(_ path: String) -> (samples: [Double], sr: Int, ch: Int)? {
guard let d = FileManager.default.contents(atPath: path), d.count > 44 else { return nil }
func rd16(_ o: Int) -> Int { Int(d[o]) | (Int(d[o+1]) << 8) }
func rd32(_ o: Int) -> Int { Int(d[o]) | (Int(d[o+1])<<8) | (Int(d[o+2])<<16) | (Int(d[o+3])<<24) }
var ch = 0, sr = 0, bits = 0
var o = 12
while o + 8 <= d.count {
let id = String(bytes: d[o..<o+4], encoding: .ascii) ?? ""
let sz = rd32(o+4)
if id == "fmt " && o + 24 <= d.count { ch = rd16(o+10); sr = rd32(o+12); bits = rd16(o+22) }
if id == "data" {
guard bits == 16, ch > 0 else { return nil }
var samples = [Double](); let start = o + 8
let end = min(start + sz, d.count - 1)
var i = start
while i + 1 < end {
var v = Int(rd16(i)); if v >= 32768 { v -= 65536 }
samples.append(Double(v) / 32768.0)
i += 2 * ch // take channel 0 if stereo
}
return (samples, sr, ch)
}
o += 8 + sz + (sz & 1)
}
return nil
}
// Audio descriptor = compact sound/voice signature (energy, ZCR, centroid, F0).
// The seed for phonetic geometry + the hear->imitate voice-signature.
func computeAudio(_ path: String) -> (content: String, vector: [Double], extra: [String: Any]) {
guard let (s, sr, ch) = readWavSamples(path), !s.isEmpty else { die("cannot read PCM from \(path)") }
let n = s.count
let seconds = Double(n) / Double(sr)
var sumsq = 0.0, peak = 0.0, zc = 0.0
for i in 0..<n {
sumsq += s[i]*s[i]; peak = max(peak, abs(s[i]))
if i > 0 && (s[i-1] < 0) != (s[i] < 0) { zc += 1 }
}
let rms = (sumsq / Double(n)).squareRoot()
let zcr = zc / Double(n) * Double(sr) // ~2*dominant freq for tonal
// Spectral centroid via a coarse DFT on a mid window (own-core).
let W = min(2048, n); let off = max(0, (n - W)/2)
var num = 0.0, den = 0.0
let bins = 64
for k in 1..<bins {
let f = Double(k) * Double(sr) / Double(2*bins)
var re = 0.0, im = 0.0
for j in 0..<W {
let ang = -2*Double.pi*Double(k)*Double(j)/Double(2*bins)
re += s[off+j]*cos(ang); im += s[off+j]*sin(ang)
}
let mag = (re*re+im*im).squareRoot()
num += f*mag; den += mag
}
let centroid = den > 0 ? num/den : 0
// F0 via autocorrelation (voice pitch) over plausible speech range 70-400 Hz.
var bestLag = 0; var bestCorr = 0.0
let lagMin = sr/400, lagMax = min(sr/70, n-1)
if lagMax > lagMin {
for lag in lagMin...lagMax {
var c = 0.0
var i = 0; while i + lag < min(n, off+W) { c += s[off+i]*s[off+i+lag]; i += 1 }
if c > bestCorr { bestCorr = c; bestLag = lag }
}
}
let f0 = bestLag > 0 ? Double(sr)/Double(bestLag) : 0
let vector: [Double] = [seconds, Double(sr), Double(ch), rms, peak, zcr, centroid, f0]
let content = String(format:
"Heard sound (afferent, mic): %.2fs at %dHz. RMS energy %.3f, peak %.3f, " +
"zero-crossing rate %.0fHz, spectral centroid %.0fHz, estimated voice pitch F0 %.0fHz. " +
"Compact voice/sound signature (%d numbers) — phonetic geometry + hear-to-imitate seed.",
seconds, sr, rms, peak, zcr, centroid, f0, vector.count)
disclose("FEAT(audio): \(vector.count)-number signature vs \(n) raw samples (~\(n/max(vector.count,1))x compression).")
return (content, vector, ["f0_hz": f0, "centroid_hz": centroid, "zcr_hz": zcr,
"rms": rms, "seconds": seconds, "raw_samples": n])
}
func featAudio(_ path: String) {
let r = computeAudio(path)
var out: [String: Any] = ["ok": true, "op": "feat-audio", "file": path,
"vector": r.vector, "content": r.content,
"ingest": ["node_type": "Observation", "tier": "Episodic", "content": r.content]]
r.extra.forEach { out[$0] = $1 }
emit(out)
}
// Image descriptor = compact scene-geometry (dims, brightness, region grid).
func computeImage(_ path: String) -> (content: String, vector: [Double], extra: [String: Any]) {
guard let src = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil),
let img = CGImageSourceCreateImageAtIndex(src, 0, nil) else { die("cannot decode image \(path)") }
let w = img.width, h = img.height
let cs = CGColorSpaceCreateDeviceRGB()
let bpr = w * 4
var buf = [UInt8](repeating: 0, count: h * bpr)
guard let ctx = CGContext(data: &buf, width: w, height: h, bitsPerComponent: 8,
bytesPerRow: bpr, space: cs,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
die("cannot rasterize image")
}
ctx.draw(img, in: CGRect(x: 0, y: 0, width: w, height: h))
// 3x3 region average luminance + overall average color.
var rAvg = 0.0, gAvg = 0.0, bAvg = 0.0
var grid = [Double](repeating: 0, count: 9); var gridN = [Int](repeating: 0, count: 9)
let step = max(1, (w*h)/40000) // subsample for speed
var count = 0; var idx = 0
while idx < w*h {
let x = idx % w, y = idx / w
let p = y*bpr + x*4
let r = Double(buf[p]), g = Double(buf[p+1]), b = Double(buf[p+2])
rAvg += r; gAvg += g; bAvg += b; count += 1
let cell = (min(2, y*3/h))*3 + min(2, x*3/w)
grid[cell] += 0.299*r + 0.587*g + 0.114*b; gridN[cell] += 1
idx += step
}
if count == 0 { die("no pixels sampled") }
rAvg /= Double(count); gAvg /= Double(count); bAvg /= Double(count)
for i in 0..<9 { grid[i] = gridN[i] > 0 ? grid[i]/Double(gridN[i]) : 0 }
let bright = (0.299*rAvg + 0.587*gAvg + 0.114*bAvg)/255.0
let vector = [Double(w), Double(h), rAvg/255, gAvg/255, bAvg/255, bright] + grid.map { $0/255 }
let content = String(format:
"Saw scene (afferent, camera): %dx%d frame. Mean color rgb(%.0f,%.0f,%.0f), " +
"brightness %.2f. 3x3 luminance grid [%.0f %.0f %.0f / %.0f %.0f %.0f / %.0f %.0f %.0f]. " +
"Compact scene-geometry (%d numbers) vs %d pixel-channels.",
w, h, rAvg, gAvg, bAvg, bright,
grid[0],grid[1],grid[2],grid[3],grid[4],grid[5],grid[6],grid[7],grid[8],
vector.count, w*h*3)
disclose("FEAT(image): \(vector.count)-number scene-geometry vs \(w*h*3) pixel-channels (~\(w*h*3/max(vector.count,1))x).")
return (content, vector, ["width": w, "height": h, "brightness": bright])
}
func featImage(_ path: String) {
let r = computeImage(path)
var out: [String: Any] = ["ok": true, "op": "feat-image", "file": path,
"vector": r.vector, "content": r.content,
"ingest": ["node_type": "Observation", "tier": "Episodic", "content": r.content]]
r.extra.forEach { out[$0] = $1 }
emit(out)
}
// The afferent WIRE hand a capture's descriptor to the ingest organ (engram),
// where it becomes an embedded node = GEOMETRY. Own-core URLSession POST.
// LOCAL only: point at a local engram; raw stream never leaves the machine.
func postNode(engramURL: String, content: String, label: String, tags: [String]) -> String? {
guard let url = URL(string: engramURL + "/api/nodes") else { return nil }
let body: [String: Any] = ["content": content, "node_type": "Observation",
"label": label, "tier": "Episodic",
"salience": 0.7, "importance": 0.6, "confidence": 0.9,
"tags": tags]
var req = URLRequest(url: url); req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
let sem = DispatchSemaphore(value: 0); var out: String?
URLSession.shared.dataTask(with: req) { data, _, _ in
if let d = data { out = String(data: d, encoding: .utf8) }
sem.signal()
}.resume()
_ = sem.wait(timeout: .now() + 15)
return out
}
func ingest(_ path: String, kind: String, engramURL: String) {
let r = kind == "audio" ? computeAudio(path) : computeImage(path)
let label = kind == "audio" ? "heard:mic" : "saw:camera"
disclose("INGEST: handing \(kind) descriptor to the ingest organ at \(engramURL) (LOCAL) -> geometry.")
guard let resp = postNode(engramURL: engramURL, content: r.content, label: label,
tags: ["peripheral", kind == "audio" ? "afferent-mic" : "afferent-camera"]) else {
die("ingest POST failed (no local engram at \(engramURL)?)")
}
// pull the node id out of the response (own-core, tolerant)
var nodeId = ""
if let d = resp.data(using: .utf8),
let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
nodeId = (o["id"] as? String) ?? (o["node_id"] as? String) ?? ""
}
disclose("INGEST: landed as node \(nodeId.isEmpty ? "(see response)" : nodeId) — the capture is now geometry in the engram.")
emit(["ok": !nodeId.isEmpty, "op": "ingest-\(kind)", "file": path,
"node_id": nodeId, "engram_response": resp, "content": r.content,
"vector": r.vector])
}
// ============================================================================
// VOICE BY IMITATION hear a voice, grab its compact SIGNATURE (pitch +
// formants F1-F5 via LPC), and speak back in that voice by source-filter
// resynthesis. Own-core DSP (physics), no training, no stolen voice. The
// afferent twin of the music instrument-signature: a voice = a few dozen
// numbers, not a corpus.
// ============================================================================
func hamming(_ x: [Double]) -> [Double] {
let n = x.count; if n < 2 { return x }
return (0..<n).map { x[$0] * (0.54 - 0.46*cos(2*Double.pi*Double($0)/Double(n-1))) }
}
func autocorr(_ x: [Double], _ p: Int) -> [Double] {
var r = [Double](repeating: 0, count: p+1)
for lag in 0...p { var s = 0.0; var i = lag; while i < x.count { s += x[i]*x[i-lag]; i += 1 }; r[lag] = s }
return r
}
// Levinson-Durbin -> LPC coeffs a[0..p] (A(z)=1+sum a[k]z^-k) and residual energy.
func levinson(_ r: [Double], _ p: Int) -> (a: [Double], err: Double) {
var a = [Double](repeating: 0, count: p+1); a[0] = 1
var err = r[0]
if err <= 0 { return (a, 0) }
for i in 1...p {
var acc = r[i]
if i > 1 { for j in 1..<i { acc += a[j]*r[i-j] } }
let k = -acc/err
var na = a; na[i] = k
if i > 1 { for j in 1..<i { na[j] = a[j] + k*a[i-j] } }
a = na; err *= (1 - k*k)
if err <= 0 { break }
}
return (a, err)
}
// Formant peaks from the LPC all-pole spectral envelope.
func formants(_ a: [Double], sr: Int) -> [(f: Double, bw: Double)] {
let p = a.count - 1
let steps = 512
var mag = [Double](repeating: 0, count: steps)
for s in 0..<steps {
let w = Double.pi * Double(s) / Double(steps) // 0..pi -> 0..sr/2
var re = 0.0, im = 0.0
for k in 0...p { re += a[k]*cos(w*Double(k)); im -= a[k]*sin(w*Double(k)) }
mag[s] = 1.0 / max((re*re+im*im).squareRoot(), 1e-9)
}
var peaks: [(f: Double, bw: Double)] = []
for s in 1..<(steps-1) where mag[s] > mag[s-1] && mag[s] >= mag[s+1] {
let f = Double(s) * Double(sr) / 2 / Double(steps)
if f > 150 && f < 5200 {
// crude bandwidth: width where magnitude falls to peak/sqrt(2)
let thr = mag[s]/1.4142
var lo = s; while lo > 0 && mag[lo] > thr { lo -= 1 }
var hi = s; while hi < steps-1 && mag[hi] > thr { hi += 1 }
let bw = Double(hi-lo) * Double(sr) / 2 / Double(steps)
peaks.append((f, bw))
}
}
return Array(peaks.prefix(5))
}
func pitchOf(_ frame: [Double], sr: Int) -> Double {
let n = frame.count
let lagMin = sr/400, lagMax = min(sr/70, n-1)
if lagMax <= lagMin { return 0 }
var r0 = 0.0; for v in frame { r0 += v*v }
if r0 < 1e-5 { return 0 }
var bestLag = 0; var best = 0.0
for lag in lagMin...lagMax { var c = 0.0; var i = lag; while i < n { c += frame[i]*frame[i-lag]; i += 1 }; if c > best { best = c; bestLag = lag } }
return (best / r0 > 0.30 && bestLag > 0) ? Double(sr)/Double(bestLag) : 0 // voiced?
}
let LPC_ORDER = 16
let FRAME = 400 // 25ms @16k
let HOP = 160 // 10ms
// Extract Will's voice-signature: averaged F0 + formants over voiced frames.
func voiceprint(_ path: String) -> (f0: Double, f0lo: Double, f0hi: Double, formants: [(Double,Double)], content: String) {
guard let (x, sr, _) = readWavSamples(path), x.count > FRAME else { die("cannot read speech from \(path)") }
var f0s: [Double] = []
var fbank: [[Double]] = [[],[],[],[],[]]
var bbank: [[Double]] = [[],[],[],[],[]]
var pos = 0
while pos + FRAME <= x.count {
let raw = Array(x[pos..<pos+FRAME])
let f0 = pitchOf(raw, sr: sr)
if f0 > 0 { // voiced frame only
f0s.append(f0)
let r = autocorr(hamming(raw), LPC_ORDER)
if r[0] > 1e-6 {
let (a, _) = levinson(r, LPC_ORDER)
let fs = formants(a, sr: sr)
for (i, fm) in fs.enumerated() where i < 5 { fbank[i].append(fm.f); bbank[i].append(fm.bw) }
}
}
pos += HOP
}
func med(_ v: [Double]) -> Double { v.isEmpty ? 0 : v.sorted()[v.count/2] }
let f0med = med(f0s)
let f0lo = f0s.isEmpty ? 0 : f0s.sorted().first!
let f0hi = f0s.isEmpty ? 0 : f0s.sorted().last!
var forms: [(Double,Double)] = []
for i in 0..<5 where !fbank[i].isEmpty { forms.append((med(fbank[i]), med(bbank[i]))) }
let fstr = forms.map { String(format:"%.0f", $0.0) }.joined(separator: "/")
let content = String(format:
"Voice-signature (afferent, heard a voice): pitch F0 %.0fHz (range %.0f-%.0fHz), " +
"formants F1-F5 = %@ Hz. Compact voiceprint (%d numbers) — grabbed by ear for imitation, not trained.",
f0med, f0lo, f0hi, fstr, 1 + forms.count*2)
return (f0med, f0lo, f0hi, forms, content)
}
// IMITATE: LPC analysis-resynthesis. Reconstruct the heard voice from its
// per-frame filter model + pitch the voice rebuilt from its signature.
func imitate(inPath: String, outPath: String) {
guard let (x, sr, _) = readWavSamples(inPath), x.count > FRAME else { die("cannot read speech from \(inPath)") }
var out = [Double](repeating: 0, count: x.count)
var state = [Double](repeating: 0, count: LPC_ORDER) // past outputs
var phase = 0.0
var lastF0 = 0.0
var pos = 0
while pos + FRAME <= x.count {
let raw = Array(x[pos..<pos+FRAME])
let r = autocorr(hamming(raw), LPC_ORDER)
let f0 = pitchOf(raw, sr: sr)
if r[0] < 1e-7 { pos += HOP; continue }
let (a, err) = levinson(r, LPC_ORDER)
let gain = max(err, 0).squareRoot()
let useF0 = f0 > 0 ? f0 : (lastF0 > 0 ? lastF0 : 0)
lastF0 = f0
for i in 0..<HOP {
let idx = pos + i; if idx >= x.count { break }
var e = 0.0
if useF0 > 0 { // voiced: glottal impulse train
phase += useF0/Double(sr)
if phase >= 1.0 { phase -= 1.0; e = sqrt(Double(sr)/useF0) } // energy-normalized impulse
} else { // unvoiced: noise
e = Double.random(in: -1...1)
}
var y = gain * e
for k in 1...LPC_ORDER { y -= a[k]*state[k-1] }
for k in stride(from: LPC_ORDER-1, through: 1, by: -1) { state[k] = state[k-1] }
state[0] = y
out[idx] = y
}
pos += HOP
}
// normalize to peak 0.9
let peak = out.map { abs($0) }.max() ?? 1
let scale = peak > 1e-9 ? 0.9/peak : 1
let samples = out.map { Int16(max(-32767, min(32767, $0*scale*32767))) }
writeWav(URL(fileURLWithPath: outPath), samples: samples, sampleRate: sr)
let vp = voiceprint(inPath)
disclose(String(format: "IMITATE: rebuilt the voice from its signature (F0 %.0fHz, formants %@) -> %@",
vp.f0, vp.formants.map{String(format:"%.0f",$0.0)}.joined(separator:"/"), outPath))
emit(["ok": true, "op": "imitate", "in": inPath, "out": outPath,
"f0_hz": vp.f0, "f0_range": [vp.f0lo, vp.f0hi],
"formants_hz": vp.formants.map { $0.0 },
"method": "LPC analysis-resynthesis (own-core, no training, no stolen voice)"])
}
// ============================================================================
// CONVERSE (full-duplex) the interruptible conversational loop.
// The utterance is a persistent, ordered meaning-plan of SEGMENTS, each with
// a salience. The speaker plays them; the mic listens concurrently. On user
// speech: pause INSTANTLY, classify (backchannel vs barge-in), then DECIDE
// yield-or-hold from the salience of the current segment + the social read.
// Yielded utterances persist their remaining plan so Neuron can RESUME.
// ============================================================================
struct Segment { let file: String; let salience: Double; let text: String }
enum Decision { case backchannelContinue, hold, yield }
// The yield-or-hold DECISION grounded, contextual. Not a fixed rule.
func decide(currentSalience: Double, progress: Double,
interrupterAuthority: Double, isBackchannel: Bool) -> Decision {
if isBackchannel { return .backchannelContinue } // "mm-hm" => keep going
// Holding the floor is justified when what I'm saying matters AND I'm nearly
// done (cheap to finish) AND the interrupter isn't high-priority.
let holdScore = currentSalience * 0.6 + progress * 0.4
if holdScore >= 0.6 && interrupterAuthority < 0.8 { return .hold }
return .yield // default: be polite, let them in
}
final class Conversation {
let engine = AVAudioEngine()
let player = AVAudioPlayerNode()
var micLive = false
// VAD state (shared with the audio tap thread)
let lock = NSLock()
var micRMS: Float = 0
var speechFrames = 0 // consecutive above-threshold frames
var onsetHandled = false
let resumePath: String
init(resumePath: String) { self.resumePath = resumePath }
// Try to bring the mic up as a live VAD. Returns false if unavailable/denied.
func startMic() -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: .audio)
if Consent.load()["mic"] != true || status != .authorized {
disclose("CONVERSE: live mic not available (consent/OS) — using injected barge events for the proof.")
return false
}
let input = engine.inputNode
// Acoustic echo cancellation: the OS voice-processing unit subtracts our
// own speaker output from the mic so Neuron does NOT hear itself and
// barge in on its own voice. This is what makes real-room barge-in work.
do { try input.setVoiceProcessingEnabled(true); disclose("CONVERSE: AEC on (echo-cancelled mic — won't self-interrupt).") }
catch { disclose("CONVERSE: AEC unavailable (\(error)); raising VAD floor instead.") }
let fmt = input.inputFormat(forBus: 0)
if fmt.sampleRate == 0 { return false }
input.installTap(onBus: 0, bufferSize: 1024, format: fmt) { [weak self] buf, _ in
guard let self = self, let ch = buf.floatChannelData?[0] else { return }
let n = Int(buf.frameLength)
var sum: Float = 0
for i in 0..<n { let v = ch[i]; sum += v*v }
let rms = n > 0 ? (sum / Float(n)).squareRoot() : 0
self.lock.lock(); self.micRMS = rms; self.lock.unlock()
}
micLive = true
disclose("CONVERSE: full-duplex — mic listening WHILE speaking (barge-in armed).")
return true
}
func run(_ segs: [Segment], interrupterAuthority: Double,
injectBargeAt: Double?, injectKind: String, startIndex: Int, liveMic: Bool) {
engine.attach(player)
let firstFmt = (try? AVAudioFile(forReading: URL(fileURLWithPath: segs[startIndex].file)))?.processingFormat
?? AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1)!
engine.connect(player, to: engine.mainMixerNode, format: firstFmt)
if liveMic { _ = startMic() }
else { disclose("CONVERSE: deterministic mode (live mic off) — barge events \(injectBargeAt != nil ? "injected" : "none").") }
do { try engine.start() } catch { die("audio engine failed to start: \(error)") }
player.play()
let injectDeadline = injectBargeAt.map { Date().addingTimeInterval($0) }
var injectedFired = false
var idx = startIndex
segmentLoop: while idx < segs.count {
let seg = segs[idx]
guard let f = try? AVAudioFile(forReading: URL(fileURLWithPath: seg.file)) else {
disclose("CONVERSE: missing segment '\(seg.file)', skipping."); idx += 1; continue
}
let dur = Double(f.length) / f.processingFormat.sampleRate
disclose(String(format: "CONVERSE: speaking segment %d/%d (salience %.2f) — \"%@\"",
idx+1, segs.count, seg.salience, seg.text))
emit(["op": "converse", "event": "speaking", "segment": idx,
"salience": seg.salience, "text": seg.text])
let done = DispatchSemaphore(value: 0)
// .dataPlayedBack: completion fires only after the audio has actually
// played OUT the DAC (not merely been consumed) so the tail is never
// clipped and playback always runs the FULL file length.
player.scheduleFile(f, at: nil, completionCallbackType: .dataPlayedBack) { _ in done.signal() }
player.play()
// Monitor this segment: poll VAD / injected event until it finishes.
let segStart = Date()
while done.wait(timeout: .now() + 0.02) == .timedOut {
let elapsed = Date().timeIntervalSince(segStart)
let progress = min(elapsed / max(dur, 0.001), 1.0)
// --- detect an onset (live mic OR injected) ---
var onset = false
if micLive {
lock.lock(); let rms = micRMS; lock.unlock()
if rms > 0.02 { speechFrames += 1 } else { speechFrames = 0 }
if speechFrames >= 3 && !onsetHandled { onset = true } // ~60ms of voice
}
if let dl = injectDeadline, !injectedFired, Date() >= dl, !onsetHandled { onset = true; injectedFired = true }
if onset {
onsetHandled = true
// (1) BARGE-IN: pause INSTANTLY, on the spot.
player.pause()
let tBarge = Date().timeIntervalSince(segStart)
disclose(String(format: "CONVERSE: << user speech at %.2fs into segment %d — PAUSED instantly >>", tBarge, idx+1))
emit(["op": "converse", "event": "barge_in", "segment": idx,
"at_seconds": tBarge, "progress": progress])
// (2) classify backchannel vs real barge-in
let isBackchannel = classifyBackchannel(injected: injectDeadline != nil,
kind: injectKind)
let d = decide(currentSalience: seg.salience, progress: progress,
interrupterAuthority: interrupterAuthority,
isBackchannel: isBackchannel)
switch d {
case .backchannelContinue:
disclose("CONVERSE: read as BACKCHANNEL (\"mm-hm\") — keep going, resume seamlessly.")
emit(["op": "converse", "event": "backchannel_continue", "segment": idx])
onsetHandled = false; speechFrames = 0
player.play() // seamless resume
case .hold:
disclose("CONVERSE: HOLD the floor — \"hang on, let me finish this thought.\" (high salience, nearly done)")
emit(["op": "converse", "event": "hold_floor", "segment": idx,
"salience": seg.salience, "progress": progress])
onsetHandled = false; speechFrames = 0
player.play() // finish the segment, THEN yield
// after this segment completes we yield the remainder
_ = done.wait(timeout: .now() + dur + 1.0)
persistResume(segs: segs, from: idx + 1, reason: "held-then-yield")
finish(); return
case .yield:
disclose("CONVERSE: YIELD — stop, let them in. Remembering where I was (resumable).")
player.stop()
persistResume(segs: segs, from: idx, reason: "yield")
emit(["op": "converse", "event": "yield", "interrupted_segment": idx,
"resume_from": idx])
finish(); return
}
}
}
emit(["op": "converse", "event": "segment_done", "segment": idx])
idx += 1
}
// whole utterance completed uninterrupted
clearResume()
disclose("CONVERSE: utterance complete (uninterrupted).")
emit(["ok": true, "op": "converse", "event": "complete", "segments": segs.count])
finish()
}
// A backchannel is brief/low. Injected kind lets us prove both paths headlessly;
// the live path would measure post-onset duration & energy.
func classifyBackchannel(injected: Bool, kind: String) -> Bool {
if injected { return kind == "backchannel" }
// live: sample ~250ms after onset; if speech already died away, it was a backchannel
Thread.sleep(forTimeInterval: 0.25)
lock.lock(); let rms = micRMS; lock.unlock()
return rms < 0.015
}
func persistResume(segs: [Segment], from: Int, reason: String) {
let remaining = segs[from...].map { ["file": $0.file, "salience": $0.salience, "text": $0.text] as [String: Any] }
let state: [String: Any] = ["resume_from": from, "reason": reason,
"remaining": remaining, "ts": Date().timeIntervalSince1970]
if let d = try? JSONSerialization.data(withJSONObject: state, options: [.prettyPrinted]) {
try? d.write(to: URL(fileURLWithPath: resumePath))
}
disclose("CONVERSE: meaning-plan persisted (\(remaining.count) segments remain) — Neuron can resume the thread.")
}
func clearResume() { try? FileManager.default.removeItem(atPath: resumePath) }
func finish() { player.stop(); if micLive { engine.inputNode.removeTap(onBus: 0) }; engine.stop() }
}
// ----------------------------------------------------------------------------
// CLI
// ----------------------------------------------------------------------------
func loadManifest(_ path: String) -> (segs: [Segment], utterance: String) {
guard let d = FileManager.default.contents(atPath: path),
let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any],
let arr = o["segments"] as? [[String: Any]] else { die("bad manifest: \(path)") }
let segs = arr.map { Segment(file: $0["file"] as? String ?? "",
salience: ($0["salience"] as? NSNumber)?.doubleValue ?? 0.5,
text: $0["text"] as? String ?? "") }
return (segs, o["utterance"] as? String ?? "")
}
let args = CommandLine.arguments
guard args.count >= 2 else {
print("""
periph — Neuron peripheral I/O (own-core, local, consent-gated)
grant <camera|mic> grant a sensitive sense (Neuron-level consent)
revoke <camera|mic> revoke it
status show consent state
speak <file.wav> SPEAK ALOUD (efferent) via the speaker
tone <out.wav> [hz] [sec] own-core synth a test WAV (no deps)
listen <sec> <out.wav> MIC capture (afferent) 16k mono
see <out.jpg> CAMERA one frame (afferent)
feat-audio <file.wav> extract compact voice/sound signature (for ingest)
feat-image <file.jpg> extract compact scene-geometry (for ingest)
ingest-audio <file.wav> <engramURL> capture -> descriptor -> engram node (geometry)
ingest-image <file.jpg> <engramURL> capture -> descriptor -> engram node (geometry)
voiceprint <voice.wav> extract voice-signature (F0 + formants F1-F5)
imitate <voice.wav> <out.wav> speak back in that voice (LPC analysis-resynthesis)
hear-imitate <sec> <out.wav> MIC -> extract signature -> imitate -> SPEAK ALOUD
wav-info <file.wav> print WAV geometry
converse <manifest.json> [--authority F] [--barge-at S[:backchannel|:bargein]] [--resume]
full-duplex interruptible utterance
""")
exit(0)
}
switch args[1] {
case "grant":
guard args.count >= 3 else { die("grant needs a device") }
var g = Consent.load(); g[args[2]] = true; Consent.save(g)
disclose("granted '\(args[2])' — the user consents; raw stream stays local, never egresses.")
emit(["ok": true, "op": "grant", "device": args[2], "consent": g])
case "revoke":
guard args.count >= 3 else { die("revoke needs a device") }
var g = Consent.load(); g[args[2]] = false; Consent.save(g)
emit(["ok": true, "op": "revoke", "device": args[2], "consent": g])
case "status":
emit(["ok": true, "op": "status", "consent": Consent.load()])
case "speak":
guard args.count >= 3 else { die("speak needs a wav") }
speak(args[2])
case "tone":
guard args.count >= 3 else { die("tone needs an out path") }
let hz = args.count >= 4 ? Double(args[3]) ?? 220 : 220
let sec = args.count >= 5 ? Double(args[4]) ?? 1.0 : 1.0
let sr = 16000
var s = [Int16](); s.reserveCapacity(Int(Double(sr)*sec))
for i in 0..<Int(Double(sr)*sec) {
let t = Double(i)/Double(sr)
let env = min(1.0, min(t*20, (sec - t)*20)) // gentle attack/release
s.append(Int16(env * 0.3 * 32767 * sin(2*Double.pi*hz*t)))
}
writeWav(URL(fileURLWithPath: args[2]), samples: s, sampleRate: sr)
disclose("tone: wrote own-core \(sec)s @ \(hz)Hz WAV to \(args[2]).")
emit(["ok": true, "op": "tone", "file": args[2], "hz": hz, "seconds": sec])
case "listen":
guard args.count >= 4 else { die("listen needs <sec> <out.wav>") }
listen(seconds: Double(args[2]) ?? 3.0, out: args[3])
case "see":
guard args.count >= 3 else { die("see needs an out path") }
see(out: args[2])
case "feat-audio":
guard args.count >= 3 else { die("feat-audio needs a wav") }
featAudio(args[2])
case "feat-image":
guard args.count >= 3 else { die("feat-image needs an image") }
featImage(args[2])
case "ingest-audio":
guard args.count >= 4 else { die("ingest-audio needs <wav> <engramURL>") }
ingest(args[2], kind: "audio", engramURL: args[3])
case "ingest-image":
guard args.count >= 4 else { die("ingest-image needs <image> <engramURL>") }
ingest(args[2], kind: "image", engramURL: args[3])
case "voiceprint":
guard args.count >= 3 else { die("voiceprint needs a wav") }
let vp = voiceprint(args[2])
disclose("VOICEPRINT: \(vp.content)")
emit(["ok": true, "op": "voiceprint", "file": args[2], "f0_hz": vp.f0,
"f0_range": [vp.f0lo, vp.f0hi], "formants_hz": vp.formants.map { $0.0 },
"bandwidths_hz": vp.formants.map { $0.1 }, "content": vp.content,
"ingest": ["node_type": "Observation", "tier": "Episodic", "content": vp.content]])
case "imitate":
guard args.count >= 4 else { die("imitate needs <voice.wav> <out.wav>") }
imitate(inPath: args[2], outPath: args[3])
case "hear-imitate":
guard args.count >= 4 else { die("hear-imitate needs <sec> <out.wav>") }
let secs = Double(args[2]) ?? 4.0
let outp = args[3]
let capp = outp.replacingOccurrences(of: ".wav", with: "") + ".heard.wav"
disclose("HEAR-IMITATE: open the ear, listen \(secs)s, grab the voice, speak it back.")
listen(seconds: secs, out: capp) // afferent: hear the voice
imitate(inPath: capp, outPath: outp) // extract signature + resynthesize
speak(outp) // efferent: speak back ALOUD in that voice
case "wav-info":
guard args.count >= 3, let i = wavInfo(args[2]) else { die("wav-info needs a readable wav") }
disclose("WAV \(args[2]): \(i.sampleRate)Hz \(i.channels)ch \(i.bits)bit \(i.frames) frames")
emit(["ok": true, "op": "wav-info", "sample_rate": i.sampleRate, "channels": i.channels,
"bits": i.bits, "frames": i.frames,
"seconds": Double(i.frames)/Double(max(i.sampleRate,1))])
case "converse":
guard args.count >= 3 else { die("converse needs a manifest") }
let (segs, utter) = loadManifest(args[2])
var authority = 0.5
var bargeAt: Double? = nil
var bargeKind = "bargein"
var resume = false
var liveMic = false
var i = 3
while i < args.count {
switch args[i] {
case "--authority": if i+1 < args.count { authority = Double(args[i+1]) ?? 0.5; i += 1 }
case "--barge-at":
if i+1 < args.count {
let parts = args[i+1].split(separator: ":")
bargeAt = Double(parts[0]) ?? nil
if parts.count > 1 { bargeKind = String(parts[1]) }
i += 1
}
case "--resume": resume = true
case "--live-mic": liveMic = true
default: break
}
i += 1
}
let resumePath = (ProcessInfo.processInfo.environment["PERIPH_HOME"]
?? FileManager.default.currentDirectoryPath + "/peripheral") + "/.resume.json"
var startIndex = 0
var runSegs = segs
if resume, let d = FileManager.default.contents(atPath: resumePath),
let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any],
let rem = o["remaining"] as? [[String: Any]] {
runSegs = rem.map { Segment(file: $0["file"] as? String ?? "",
salience: ($0["salience"] as? NSNumber)?.doubleValue ?? 0.5,
text: $0["text"] as? String ?? "") }
startIndex = 0
disclose("CONVERSE: resuming — \"as I was saying...\" (\(runSegs.count) segments left).")
emit(["op": "converse", "event": "resume", "remaining": runSegs.count])
}
if runSegs.isEmpty { die("no segments to speak") }
disclose("CONVERSE: utterance = \"\(utter)\" (\(runSegs.count) segments).")
let convo = Conversation(resumePath: resumePath)
convo.run(runSegs, interrupterAuthority: authority,
injectBargeAt: bargeAt, injectKind: bargeKind, startIndex: startIndex, liveMic: liveMic)
default:
die("unknown command: \(args[1])")
}