// Command curiosity implements the autonomous curiosity loop. // // After corpus training, the morpheme lattice walks its own weak regions // to produce fragmentary "baby talk" queries, searches archive.org and // Gutenberg for matching texts, fetches one text at a time, feeds it // through the morpheme enzyme, and evaluates whether it helped. When // nothing new bonds, it stops. // // All queries and ingested texts are recorded in curiosity_journal.log // for post-run review. package main import ( "context" "encoding/json" "flag" "fmt" "io" "log" "net/http" "os" "os/signal" "strings" "time" "crypto/rand" "encoding/binary" "git.mleku.dev/mleku/dendrite/pkg/axiom" "git.mleku.dev/mleku/dendrite/pkg/forage" "git.mleku.dev/mleku/dendrite/pkg/gap" "git.mleku.dev/mleku/dendrite/pkg/grammar" "git.mleku.dev/mleku/dendrite/pkg/grow" "git.mleku.dev/mleku/dendrite/pkg/lattice" "git.mleku.dev/mleku/dendrite/pkg/mindsicle" "git.mleku.dev/mleku/dendrite/pkg/reflex" ) // curiosityCheckpoint records loop state for resumption. type curiosityCheckpoint struct { Round int `json:"round"` SeenURLs map[string]string `json:"seen_urls"` // url → title TotalBonded int `json:"total_bonded"` BondRateEWMA float64 `json:"bond_rate_ewma"` ZeroStreak int `json:"zero_streak"` Seed uint64 `json:"seed"` SavedAt string `json:"saved_at"` } func main() { var ( latticePath = flag.String("lattice", ".condense_morpheme_fast_db.mindsicle.bin", "path to morpheme mindsicle") arcsPath = flag.String("arcs", ".condense_rune_db.arcs.bin", "path to reflex arcs binary") walkLen = flag.Int("walk-len", 5000, "walk steps per gap probe") maxRounds = flag.Int("max-rounds", 0, "0 = infinite until convergence") zeroLimit = flag.Int("zero-streak", 5, "stop after N consecutive zero-bond texts") fetchRate = flag.Duration("rate", 3*time.Second, "delay between fetches") bandwidth = flag.Int64("bandwidth", 0, "total session bandwidth cap in bytes (0=unlimited)") seed = flag.Uint64("seed", 0, "random seed (0=time-based)") outputDB = flag.String("output", ".curiosity_db", "checkpoint file prefix") journalPath = flag.String("journal", "curiosity_journal.log", "audit log path") ) flag.Parse() if *seed == 0 { *seed = uint64(time.Now().UnixNano()) } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() // --- Load reflex arcs --- log.Printf("curiosity: loading arcs from %s", *arcsPath) arcFile, err := os.Open(*arcsPath) if err != nil { log.Fatalf("curiosity: cannot open arcs: %v", err) } arcs, err := reflex.Load(arcFile) arcFile.Close() if err != nil { log.Fatalf("curiosity: cannot load arcs: %v", err) } log.Printf("curiosity: loaded %d arcs", len(arcs)) arcIdx := reflex.BuildArcIndex(arcs) enzyme := reflex.NewMorphemeEnzyme(arcIdx) cf := func(tag string) axiom.Constraint { return grammar.NewConstraint(tag, grammar.MorphemeText) } // --- Load lattice --- log.Printf("curiosity: loading lattice from %s", *latticePath) f, err := os.Open(*latticePath) if err != nil { log.Fatalf("curiosity: cannot open lattice: %v", err) } lat, _, err := mindsicle.StreamThaw(f, cf) f.Close() if err != nil { log.Fatalf("curiosity: cannot thaw lattice: %v", err) } h := lat.Health() log.Printf("curiosity: lattice loaded: %d/%d occupied (%.1f%%)", h.Occupied, h.NodeCount, h.OccupancyRate.Float64()*100) // Build walk columns for cache-friendly walks. lat.BuildAndAttachWalkColumns() wc := lat.GetWalkColumns() log.Printf("curiosity: walk columns built (%d nodes)", lat.Size()) // --- Load or create checkpoint --- ckptPath := *outputDB + ".checkpoint.json" var ckpt curiosityCheckpoint if data, err := os.ReadFile(ckptPath); err == nil { if err := json.Unmarshal(data, &ckpt); err == nil { log.Printf("curiosity: resumed from round %d (%d URLs seen, streak=%d)", ckpt.Round, len(ckpt.SeenURLs), ckpt.ZeroStreak) *seed = ckpt.Seed } } if ckpt.SeenURLs == nil { ckpt.SeenURLs = make(map[string]string) } ckpt.Seed = *seed // --- Open journal --- journal, err := os.OpenFile(*journalPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { log.Fatalf("curiosity: cannot open journal: %v", err) } defer journal.Close() fmt.Fprintf(journal, "# curiosity session started at %s (seed=%d)\n", time.Now().Format(time.RFC3339), *seed) // --- Bandwidth tracker --- var totalBytesUsed int64 // --- Main loop --- startRound := ckpt.Round + 1 if *bandwidth > 0 { log.Printf("curiosity: starting from round %d (walk=%d, zero-limit=%d, bandwidth=%dMB)", startRound, *walkLen, *zeroLimit, *bandwidth/(1<<20)) } else { log.Printf("curiosity: starting from round %d (walk=%d, zero-limit=%d, bandwidth=unlimited)", startRound, *walkLen, *zeroLimit) } for round := startRound; ; round++ { if *maxRounds > 0 && round > *maxRounds { log.Printf("curiosity: max rounds reached (%d)", *maxRounds) break } select { case <-ctx.Done(): log.Printf("curiosity: interrupted at round %d, saving", round) saveCheckpoint(ckptPath, ckpt) saveLattice(lat, *latticePath) return default: } // 1. Detect gaps. gaps := gap.DetectGaps(lat, nil, nil) // 2. Walk weak regions. walkSeed := *seed + uint64(round)*0xbeef walkedValues := forage.WeakRegionWalk(lat, wc, *walkLen, walkSeed) // 3. Extract baby talk query. query := forage.ExtractBabyTalk(walkedValues) if query == "" && len(gaps) > 0 { // Use gap description as fallback query. query = gaps[0].Description } // 4. Search. var pick *forage.SearchResult if query != "" { log.Printf("curiosity: round %d: query=%q", round, truncate(query, 80)) results := forage.SearchAll(ctx, query) // Convert seenURLs to the format PickBestResult expects. seenSet := make(map[string]bool, len(ckpt.SeenURLs)) for u := range ckpt.SeenURLs { seenSet[u] = true } pick = forage.PickBestResult(results, seenSet) } // 5. Fallback to random Gutenberg. if pick == nil { r := forage.RandomGutenbergURL(walkSeed) seenSet := make(map[string]bool, len(ckpt.SeenURLs)) for u := range ckpt.SeenURLs { seenSet[u] = true } if !seenSet[r.URL] { pick = &r } if pick != nil { log.Printf("curiosity: round %d: fallback to %s", round, pick.Title) } } if pick == nil { log.Printf("curiosity: round %d: no unseen texts available, stopping", round) break } // 6. Fetch the text. if *bandwidth > 0 && totalBytesUsed >= *bandwidth { log.Printf("curiosity: bandwidth cap reached (%d bytes), stopping", totalBytesUsed) break } var fetchLimit int64 if *bandwidth > 0 { fetchLimit = *bandwidth - totalBytesUsed } else { fetchLimit = 50 << 20 // 50MB per-fetch safety cap } text, size, err := fetchText(ctx, pick.URL, fetchLimit) if err != nil { log.Printf("curiosity: round %d: fetch failed: %v", round, err) ckpt.SeenURLs[pick.URL] = pick.Title // mark as seen to skip next time ckpt.ZeroStreak++ continue } totalBytesUsed += int64(size) log.Printf("curiosity: -> %s %q (%s) %s", pick.Source, truncate(pick.Title, 50), formatBytes(size), pick.URL) // 7. Digest through morpheme enzyme and feed into lattice. elems := enzyme.DigestText(text) bonded := feedElements(ctx, lat, elems) // 8. Dissolve weak bonds. dissolved := dissolveWeak(lat) // 9. Update health and convergence. h = lat.Health() bondRate := 0.0 if len(elems) > 0 { bondRate = float64(bonded) / float64(len(elems)) } ckpt.BondRateEWMA = 0.8*ckpt.BondRateEWMA + 0.2*bondRate ckpt.TotalBonded += bonded ckpt.Round = round ckpt.SeenURLs[pick.URL] = pick.Title if bonded == 0 { ckpt.ZeroStreak++ } else { ckpt.ZeroStreak = 0 } // 10. Log to terminal and journal. log.Printf("curiosity: digest: %d morphemes, bonded: %d (%.1f%%), dissolved: %d", len(elems), bonded, bondRate*100, dissolved) log.Printf("curiosity: health: %dK/%dK (%.1f%%) streak: %d ewma: %.4f", h.Occupied/1000, h.NodeCount/1000, h.OccupancyRate.Float64()*100, ckpt.ZeroStreak, ckpt.BondRateEWMA) fmt.Fprintf(journal, "%s round=%d query=%q source=%s title=%q url=%s size=%s morphemes=%d bonded=%d rate=%.1f%% health=%.1f%% streak=%d\n", time.Now().Format(time.RFC3339), round, truncate(query, 60), pick.Source, pick.Title, pick.URL, formatBytes(size), len(elems), bonded, bondRate*100, h.OccupancyRate.Float64()*100, ckpt.ZeroStreak) // 11. Checkpoint every 5 rounds. if round%5 == 0 { saveCheckpoint(ckptPath, ckpt) saveLattice(lat, *latticePath) log.Printf("curiosity: checkpoint saved at round %d", round) } // 12. Dynamic growth: expand lattice when occupancy > 80%. if h.OccupancyRate.Float64() > 0.80 { growBy := h.NodeCount / 2 // grow by 50% of current size var expandSeed [32]byte binary.LittleEndian.PutUint64(expandSeed[:], *seed+uint64(round)*0xdead) rand.Read(expandSeed[8:]) // mix in true randomness added := grammar.ExpandLattice(lat, grammar.MorphemeText, growBy, expandSeed, cf, grammar.MorphemeDefaultCounts) if added > 0 { // Rebuild walk columns with new nodes. lat.BuildAndAttachWalkColumns() wc = lat.GetWalkColumns() log.Printf("curiosity: expanded lattice by %d nodes (now %d)", added, lat.Size()) // Reset zero streak — new capacity means new opportunities. ckpt.ZeroStreak = 0 } } // 13. Check convergence: only zero-streak stops the loop now. if ckpt.ZeroStreak >= *zeroLimit { log.Printf("curiosity: converged — %d consecutive zero-bond texts", ckpt.ZeroStreak) break } // Rate limit. select { case <-time.After(*fetchRate): case <-ctx.Done(): } } // Final save. saveCheckpoint(ckptPath, ckpt) saveLattice(lat, *latticePath) h = lat.Health() fmt.Fprintf(journal, "# curiosity session ended at %s: %d rounds, %d total bonded, %dK/%dK occupied (%.1f%%)\n", time.Now().Format(time.RFC3339), ckpt.Round, ckpt.TotalBonded, h.Occupied/1000, h.NodeCount/1000, h.OccupancyRate.Float64()*100) log.Printf("curiosity: done — %d rounds, %d total bonded, %d URLs explored", ckpt.Round, ckpt.TotalBonded, len(ckpt.SeenURLs)) } // fetchText downloads a plain text file from a URL, respecting a byte limit. func fetchText(ctx context.Context, rawURL string, maxBytes int64) (string, int, error) { req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil) if err != nil { return "", 0, err } req.Header.Set("User-Agent", "dendrite-curiosity/0.1") resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return "", 0, err } defer resp.Body.Close() if resp.StatusCode != 200 { return "", 0, fmt.Errorf("status %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes)) if err != nil { return "", 0, err } return string(body), len(body), nil } // feedElements feeds elements into the lattice via grow.Run. func feedElements(ctx context.Context, lat *lattice.Lattice, elems []axiom.Element) int { if len(elems) == 0 { return 0 } ch := make(chan axiom.Element, len(elems)) go func() { for _, e := range elems { ch <- e } close(ch) }() cfg := grow.Config{ MaxSteps: 10, Workers: grow.WorkerCount(), } events := make(chan grow.Event, 256) bonded := 0 done := make(chan struct{}) go func() { for ev := range events { if ev.Type == grow.EventBonded { bonded++ } } close(done) }() grow.Run(ctx, lat, ch, cfg, events) close(events) <-done return bonded } // dissolveWeak removes occupants with low contextual lock-in. func dissolveWeak(l *lattice.Lattice) int { nodes := l.Nodes() target := max(1, len(nodes)/100) type scored struct { node *lattice.Node score float64 } var weak []scored for _, n := range nodes { if !n.Occupied() { continue } s := n.ContextualLockIn().Float64() if s < 0.3 { weak = append(weak, scored{n, s}) } } dissolved := 0 for _, s := range weak { if dissolved >= target { break } s.node.Dissolve() l.ReindexVacant(s.node) dissolved++ } return dissolved } // saveCheckpoint writes the curiosity state for resumption. func saveCheckpoint(path string, ckpt curiosityCheckpoint) { ckpt.SavedAt = time.Now().Format(time.RFC3339) data, err := json.MarshalIndent(ckpt, "", " ") if err != nil { log.Printf("curiosity: WARNING: cannot marshal checkpoint: %v", err) return } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { log.Printf("curiosity: WARNING: cannot write checkpoint: %v", err) return } os.Rename(tmp, path) } // saveLattice writes the lattice atomically. func saveLattice(lat *lattice.Lattice, path string) { tmp := path + ".tmp" f, err := os.Create(tmp) if err != nil { log.Printf("curiosity: WARNING: cannot create %s: %v", tmp, err) return } if err := mindsicle.StreamFreeze(lat, nil, f); err != nil { f.Close() os.Remove(tmp) log.Printf("curiosity: WARNING: save failed: %v", err) return } f.Close() if err := os.Rename(tmp, path); err != nil { log.Printf("curiosity: WARNING: rename failed: %v", err) return } fi, _ := os.Stat(path) if fi != nil { log.Printf("curiosity: saved %.2f MB", float64(fi.Size())/(1024*1024)) } } func truncate(s string, n int) string { s = strings.ReplaceAll(s, "\n", " ") if len(s) > n { return s[:n] + "..." } return s } func formatBytes(n int) string { switch { case n >= 1<<20: return fmt.Sprintf("%.1fMB", float64(n)/float64(1<<20)) case n >= 1<<10: return fmt.Sprintf("%.1fKB", float64(n)/float64(1<<10)) default: return fmt.Sprintf("%dB", n) } }