Memory Distillation — How Experience Is Extracted, Ranked, Reused, and Forgotten
Ever run an agent that, after a few hours, tries to remember every task / input / output it ever saw, and the memory only grows while the thing actually worth remembering — "I hit this trap last time" — gets lost in the noise? I kept thinking: instead of remembering "what happened," remember "what it means and what to do next time." And that produced this whole pile: one pipeline that distills task results into reusable
Experience, one that distills conversations intoMemory, and one that records task-pattern → skill relevance priors asExperience. They're all named "experience," but they're three different jobs.
Reading note: this article only describes symbols and flows that genuinely exist in the current repo and that I could verify. Anything I couldn't find in the code is explicitly marked (待核实 / unverified) — I won't fabricate an "architecture that should exist."
Series Navigation
| # | Topic | Status |
|---|---|---|
| 03 | Memory Distillation: Experience distillation / Memory distillation / Skill priors | This article |
| 04 | Workflow Engine (preview) | Next |
1. Get One Thing Straight: ares Has THREE "Distillation" Tracks
The package names all look like "experience / memory," but they're three unrelated pipelines. Don't mix them up:
- Experience distillation (
internal/runtime/memory/experience): distills a task execution result (TaskResult) into a reusable, rankable, feedback-drivenExperience. This is the star of this article. - Memory distillation (
internal/runtime/memory/distillation+pipeline.go): distills a conversation into classifiedMemory(knowledge/preference/interaction/profile), producing reports and pushing. - Skill priors (
internal/runtime/protocol/skills): remembers "task-pattern → skill relevance" priors, used as a confidence source by the scheduler.
They share the word "experience" but differ in inputs, outputs, and lifecycles. Walk through them one by one — only what I read in the code.
2. Experience Distillation: From TaskResult to Experience
2.1 Entry Point: Task Completed / Failed Events
Experience distillation isn't call-ed by someone directly — it runs on an event-subscription loop. subscribeDistillationEvents in internal/ares_bootstrap/bootstrap_steps.go subscribes to two event types:
EventTaskCompletedEventTaskFailed
For each event it hands HandleTaskCompletedForDistillation to the same loop. The real entry, in intent:
// internal/ares_bootstrap/provide_distillation.go (excerpt)
func HandleTaskCompletedForDistillation(ctx context.Context, svc *aresexp.DistillationService, ev *ares_events.Event) {
taskText := stringField(p, ares_events.EventKeyTask)
resultText := stringField(p, ares_events.EventKeyResult)
tenantID := stringField(p, ares_events.EventKeyTenantID)
agentID := stringField(p, "agent_id")
usedExpID := stringField(p, ares_events.EventKeyUsedExperienceID)
// content-length guard: task < 10 chars or result < 20 chars → skip
if tenantID == "" || len(taskText) < 10 || len(resultText) < 20 {
return
}
taskResult := &aresexp.TaskResult{
Task: taskText, Result: resultText,
TenantID: tenantID, AgentID: agentID,
UsedExperienceID: usedExpID,
Success: ev.Type == ares_events.EventTaskCompleted,
}
if _, err := svc.Distill(ctx, taskResult); err != nil {
log.Warn("bootstrap: distillation on task completion failed", "error", err)
}
}
The input struct TaskResult lives in internal/runtime/memory/experience/task_result.go:
type TaskResult struct {
Task string // task description (the distillation input text)
Context string // extra context
Result string // the task output/result
Success bool // whether the task succeeded
AgentID string
TenantID string // multi-tenant isolation
UsedExperienceID string // experience used by this execution (reinforcement tracking)
}
Key clarification: both successful and failed tasks participate in distillation. The doc comment states: "Both successful and failed tasks are eligible: successful tasks yield success experiences, while failed tasks yield failure experiences." So "only successful tasks are distilled" is wrong.
2.2 Main Flow: Distill
DistillationService (internal/runtime/memory/experience/distillation_service.go) exposes the core method Distill, whose pipeline looks like this:
flowchart LR
EV[EventTaskCompleted<br/>EventTaskFailed] --> SUB[subscribeDistillationEvents loop]
SUB --> HANDLE[HandleTaskCompletedForDistillation]
HANDLE --> D[DistillationService.Distill]
D --> GUARD[ShouldDistill guard<br/>nil→false / lenTask>=10 / lenResult>=20]
GUARD -->|fails| SKIP[return skipped error]
GUARD -->|passes| EX[extractExperience<br/>LLM Generate<br/>30s timeout]
EX --> LE[ExtractedExperience<br/>Problem / Solution / Constraints]
LE -->|Problem or Solution empty| ERR[invalid extracted experience]
LE --> ST[build storage_models.Experience<br/>Type=success/failure]
ST -->|default, no enqueuer| SYNC[embedAndCreate<br/>sync embed + Create]
ST -->|WithEmbeddingEnqueuer| ASYNC[Create row<br/>enqueueEmbeddingBackfill]
ASYNC -->|enqueue fails, fallback| FB[backfillEmbedding sync]
SYNC --> OUT[expToExperience returns *Experience]
FB --> OUT
Real, verifiable points inside Distill:
The gate ShouldDistill — only looks at content length, not success:
func (s *DistillationService) ShouldDistill(ctx context.Context, task *TaskResult) bool {
if task == nil { return false }
if len(task.Task) < 10 { return false }
if len(task.Result) < 20 { return false }
return true
}
LLM extraction extractExperience — builds a "return Problem/Solution/Constraints" prompt, calls llmClient.Generate (30s timeout), then parseExtractionResponse parses line-by-line into ExtractedExperience:
type ExtractedExperience struct {
Problem string
Solution string
Constraints string
}
Experience type — decided by task.Success:
expType := ExperienceTypeFailure // "failure"
if task.Success { expType = ExperienceTypeSuccess } // "success"
Persistence / vectors — two legs (the /a2 async backfill option):
- Default (no
EmbeddingEnqueuerinjected):embedAndCreateembeds synchronously andCreates the row with its vector. - With
WithEmbeddingEnqueuer: writes the row first (Embeddingstays NULL), thenenqueueEmbeddingBackfilldrops a backfill task onto the queue; an embedding worker writes the vector back asynchronously. If the enqueue fails, it logs a Warn and falls back to synchronous backfill so the row never sits without a vector.
The interface (defined in the consuming package so DistillationService doesn't depend on the concrete postgres queue):
type EmbeddingEnqueuer interface {
Enqueue(ctx context.Context, task *EmbeddingTask) error
}
type EmbeddingTask struct {
TaskID string // i.e. the experience row id; the vector is written back here
Content string
TenantID string
Model string
Version int
}
There's also a batch entry DistillBatch(ctx, []*TaskResult): distills each one; a single failure is logged and skipped, the rest continue.
Two behaviors I verified: ① the async backfill is optional (bootstrap configures it by default); a zero-config SDK caller still goes synchronous. ② both paths stamp
EmbeddingModel/EmbeddingVersion(version fromembeddingConfig.DefaultVersion, falling back topostgres.DefaultEmbeddingConfig().DefaultVersionwhen unset).
2.3 The Domain Model: What an Experience Looks Like
The Experience returned by the service is defined in internal/runtime/memory/experience/ranked_experience.go (mirrors the stored row):
type Experience struct {
ID string
TenantID string // multi-tenant isolation
Type string // "success" or "failure"
Problem string // abstract problem statement (also the embedding target)
Solution string // concise solution
Constraints string // important constraints/context
Embedding []float64
EmbeddingModel string
EmbeddingVersion int
Score float64 // overall score (bandit feedback signal; RecordFailure lowers it)
Success bool // whether the original task succeeded
AgentID string // agent that produced it
UsageCount int // times successfully used (reinforcement signal)
DecayAt time.Time // expiry; zero = never expires
CreatedAt time.Time
}
Three design decisions genuinely in the code here: Problem/Solution/Constraints separation, Score as a feedback-mutated score, and UsageCount as a reuse reinforcement signal.
3. The Feedback Loop: FeedbackService (bandit)
Experiences don't just sit after being stored. FeedbackService (feedback_service.go) feeds task outcomes back into the loop:
// a task succeeded using an experience → usage count +1
func (s *FeedbackService) RecordSuccess(ctx, tenantID, experienceID) error {
return s.experienceRepo.IncrementUsageCount(ctx, tenantID, experienceID)
}
// a task failed using an experience → rank drops (Score goes down)
func (s *FeedbackService) RecordFailure(ctx, tenantID, experienceID) error {
return s.experienceRepo.DecrementRank(ctx, tenantID, experienceID)
}
// convenience: dispatch to the two above by success flag
func (s *FeedbackService) RecordFeedback(ctx, tenantID, experienceID string, success bool) error
One sentence for the code logic: used successfully once → UsageCount+1; used and failed once → Score drops (DecrementRank). This yields two independent signals for ranking.
4. Ranking: RankingService (multi-signal weighting)
Experiences aren't enough by themselves — after recall you have to order them. RankingService (ranking_service.go) implements a "lightweight bandit" multi-signal scoring.
Formula (confirmed in both comments and implementation):
FinalScore = SemanticScore + UsageBoost + RecencyBoost + persisted Score
where:
UsageBoost = min( log(1 + usage_count) * UsageWeight , 0.2 )
RecencyBoost = exp( -age_days / RecencyDays ) * RecencyWeight
Defaults are hardcoded in NewRankingService / DefaultRankingWeights:
| Param | Default | Meaning |
|---|---|---|
| UsageWeight | 0.05 | boost coefficient per log usage |
| RecencyWeight | 0.05 | boost coefficient for recent experiences |
| RecencyDays | 30.0 | decay half-life (days) |
Real code excerpt (Rank scores each experience):
func (s *RankingService) Rank(ctx context.Context, experiences []*Experience, baseScores []float64) ([]*RankedExperience, error) {
// error when len(experiences) != len(baseScores)
...
finalScore := semanticScore + usageBoost + recencyBoost + exp.Score
ranked[i] = &RankedExperience{
Experience: exp,
FinalScore: finalScore,
SemanticScore: semanticScore,
UsageBoost: usageBoost,
RecencyBoost: recencyBoost,
ConflictChecked: false,
}
...
sort.Slice(ranked, func(i, j int) bool { // descending by FinalScore
return ranked[i].FinalScore > ranked[j].FinalScore
})
}
Real boost implementations:
calculateUsageBoost:log1p(usageCount) * weight, capped at0.2, so old experiences can't dominate by sheer count.calculateRecencyBoost:exp(-ageDays/recencyDays) * weight, 30-day half-life, naturally aging out.
RankedExperience carries two booleans reserved for conflict detection:
type RankedExperience struct {
Experience *Experience
FinalScore float64
SemanticScore float64
UsageBoost float64
RecencyBoost float64
ConflictChecked bool
ConflictResolved bool // if true, this one was chosen as best in its conflict group
}
5. Conflict Resolution: ConflictResolver (Store Simply, Retrieve Smartly)
ConflictResolver (conflict_resolver.go) embodies one principle: "Store Simply, Retrieve Smartly."
It does NOT dedupe at write time; instead it does lazy conflict grouping after recall/ranking. Two steps:
DetectConflictGroups: clusters experiences by cosine similarity of their problem vectors (O(K²) single-link), grouping any pair above the threshold. Default threshold0.9(problemSimilarityThresholdinNewConflictResolver).Resolve: within each group, keeps the highest-FinalScoreone, and marks the othersConflictResolved(only the winner getstrue).
flowchart LR
RANK[RankingService.Rank<br/>→ ranked list] --> RES[ConflictResolver.Resolve]
RES --> GROUPS[DetectConflictGroups<br/>cosine sim > threshold=0.9<br/>O(K²) single-link]
GROUPS --> BEST[per group: keep highest FinalScore]
BEST --> WIN[return: one best experience per group]
BEST --> MARK[others marked ConflictChecked=true<br/>winner marked ConflictResolved=true]
Real-code essentials:
func (c *ConflictResolver) cosineSimilarity(vec1, vec2 []float64) float64 {
if len(vec1) != len(vec2) { // dimension mismatch → 0
return 0.0
}
// standard dot / (|v1|*|v2|)
}
func (c *ConflictResolver) Configure(problemSimilarityThreshold float64) error {
// must be 0 < threshold <= 1, else error
}
Default threshold is
0.9(overridable viaConfigure). Note this is a different conflict mechanism frominternal/runtime/memory/distillation'sDistillationConfig.ConflictThreshold = 0.85— one is the experience-ranking layer (ares_experience), the other is the conversation-distillation layer (ares_memory/distillation). Don't confuse them.
6. Evolution Guidance & Spawn Priors: How Experience Flows Downstream
The experiences produced have two downstream paths, both wired in internal/ares_bootstrap/provide_distillation.go.
6.1 Path A: Feeding the Evolution Algorithm (GA) as Guidance
Bootstrap builds an evolution.FuncGuidanceProvider that turns the experience store into the GA's "hint source":
HintsFunc: callsfetchExperiences— preferably embeds the task type and does a semantic vector search (Embed(taskType)→SearchByVector), falling back toSearchByKeyword; maps hits intoevolution.EvolutionHint(Problem/Solution/Constraints/Confidence).RecordFunc:recordStrategyOutcome— writes back the real GA strategy result as an experience, closing the Strategy → Experience → Guidance loop. Failures return errors, never silently swallowed.
In other words: an agent's last success/failure becomes the hint for the next evolutionary mutation. Here Confidence comes from the experience's Score, clamped to [0,1].
6.2 Path B: Injecting Into a New Agent's Cognitive Context (Spawn prior)
In internal/fabric/agent/lifecycle.go, SpawnSpec has a field ExperiencePrior any:
type SpawnSpec struct {
// ...
// ExperiencePrior is the distilled prior experience, loaded as the agent's
// initial cognitive context at spawn time (written into CognitiveState.Context)
// so the agent starts with reusable experience instead of a blank slate.
// Nil = no prior (zero-value usable).
ExperiencePrior any
}
And the matching logic in Fabric.Spawn (G1: memory-distill hook into the agent lifecycle):
if spec.ExperiencePrior != nil {
a.cognitive = CognitiveState{
SchemaVersion: CognitiveStateSchemaVersion,
Context: spec.ExperiencePrior,
}
}
So "distilled experience can become a new agent's initial cognition" is a real, field-visible path. But an honest boundary: ExperiencePrior is an injection point at spawn time; I could NOT find a wiring that automatically searches the experience store's top-1 and fills it into ExperiencePrior. Whether such an automatic backfill already exists I mark as (待核实 / unverified).
7. Memory Distillation: Turning Conversations Into Classified Memory
If the above is "task-level" distillation, internal/runtime/memory/distillation handles "conversation-level" distillation. The entry Distiller.DistillConversation is a multi-stage orchestration (real phases in distiller.go):
extractPhase → classifyAndScorePhase → topNBeforeConflictPhase
→ embedPhase → resolveConflictsPhase → finalTopNPhase
Combined with the Pipeline coordinator in internal/runtime/memory/pipeline.go, it forms an end-to-end flow:
flowchart LR
SRC[ConversationSource.Next] -->|ConversationBatch<br/>ConversationID/TenantID/UserID/Messages| P[Pipeline.Run]
P --> D[Distiller.DistillConversation]
D --> E[extract phase]
E --> C[classify & score<br/>MemoryType: knowledge/preference/interaction/profile]
C --> T[topNBeforeConflict]
T --> EMB2[embed phase]
EMB2 --> RES2[resolveConflicts]
RES2 --> FINAL[finalTopN → []Memory]
FINAL -->|optional PushAfterDistill| PUSH[PushService.PushRelevant]
FINAL -->|per ReportInterval / at end| RPT[ReportGenerator.Generate<br/>→ reportSink.Save]
Real key interfaces & config:
// pipeline.go
type ConversationSource interface {
Next(ctx context.Context) (*ConversationBatch, error) // io.EOF when exhausted
}
type Distiller interface { // satisfied by *distillation.Distiller
DistillConversation(ctx, conversationID string, messages []distillation.Message,
tenantID, userID string) ([]distillation.Memory, error)
}
type PipelineConfig struct {
TenantID string
ReportInterval time.Duration // 0 = one report at the end of each Run
PushAfterDistill bool
GenerateReportAtEnd bool
}
Pipeline philosophy is explicit: depends only on interfaces, never concrete types; a failing batch is logged and continued, never panics. Run returns PipelineRunResult (TotalBatches/TotalMemories/FailedBatches/PushedItems/Duration…).
Key DistillationConfig defaults (all word-for-word verifiable in distiller.go):
| Config | Default | Meaning |
|---|---|---|
| MinImportance | 0.6 | min importance for a memory to be kept |
| ConflictThreshold | 0.85 | conflict similarity threshold (conversation layer) |
| MaxMemoriesPerDistillation | 3 | max memories kept per distillation |
| MaxSolutionsPerTenant | 5000 | per-tenant cap on solution-type memories |
| EnableCode/Stacktrace/Log/MarkdownTableFilter | true | various noisy filters on by default |
| EnableCrossTurnExtraction | true | cross-turn extraction on by default |
| PrecisionOverRecall | true | precision takes priority over recall |
Four memory classifications (canonical definitions live in internal/llmexp; internal/runtime/memory/distillation/ aliases to it (the old ares_memory/ and api/experience paths were renamed/removed in the M5 internalization)):
type MemoryType string
const (
MemoryKnowledge MemoryType = "knowledge"
MemoryPreference MemoryType = "preference"
MemoryInteraction MemoryType = "interaction"
MemoryProfile MemoryType = "profile"
)
const (
ExtractionDirect = "direct" // direct user-assistant pair extraction
ExtractionCrossTurn = "cross-turn" // multi-turn conversation extraction
)
And the write-time conflict resolution strategies (when an ExperienceStore is configured):
type ResolutionStrategy string
const (
ReplaceOld Strategy = "replace" // new replaces old
KeepOld Strategy = "keep_old" // higher-confidence old wins over incoming
KeepBoth Strategy = "version" // keep both (competing solutions)
Merge Strategy = "merge" // merge (reserved for future use)
)
8. Session & Task Memory: The Underlying Tools
ProductionMemoryManager leans on two in-memory stores in internal/runtime/memory/context:
SessionMemory: holds sessions inmap[string]*SessionData,SessionData{SessionID, UserID, Messages, Context, AccessedAt, CreatedAt}. A backgroundStartCleanupprunes expired sessions every TTL/2;Cleanupremoves at most 100 per call (avoids holding the lock too long).TaskMemory: holds tasks inmap[string]*TaskData;TaskDataalso tracksSteps []StepRecord/Results []ResultRecord.TaskMemory.Distillcollapses a task intomodels.Task(input/output/context) for the "lightweight extraction" path.
MemoryConfig (manager.go) defaults — all from DefaultMemoryConfig, verifiable:
| Field | Default |
|---|---|
| MaxHistory | 10 |
| MaxSessions | 100 |
| MaxTasks | 1000 |
| MaxDistilledTasks | 5000 |
| SessionTTL | 24h |
| TaskTTL | 7d |
| DistilledTaskTTL | 30d |
| VectorDim | 128 |
| EnableRAG | false (opt-in) |
| RAGTopK | 5 (applied when enabled) |
| RAGMinScore | 0.4 (applied when enabled) |
RAG is opt-in. With
EnableRAG=false,BuildContext/BuildPromptMessagesbehave as before (history only); set true to retrieve past experiences/distilled memories into the prompt.ProductionMemoryManageralso hasretrievers []memctx.ContextRetrieverused when RAG is on.
9. The MemoryManager Abstraction & the Production Implementation
Experience distillation (ares_experience) runs on its own; the unified memory-access abstraction is MemoryManager (internal/runtime/memory/manager.go), with three blocks:
- Session: CreateSession / AddMessage / GetMessages / AddStructuredMessage / BuildPromptMessages / BuildContext / DeleteSession
- Task: CreateTask / CreateTaskWithID / UpdateTaskOutput / DistillTask / StoreDistilledTask / SearchSimilarTasks
- Lifecycle & events: GetLatestSessionForAgent / Start / Stop / SetEventStore
The production implementation ProductionMemoryManager (production_manager.go) composes a lot: dbPool (PostgreSQL + pgvector), retrievalService, writeBuffer (write buffering), embeddingClient, conversationRepository, taskResultRepository, sessionCache (in-memory hot-session cache), ctxCleaner (strips tool noise / compresses verbosity), optional eventStore and EvidenceCollector.
EvidenceCollector is a minimal interface:
type EvidenceCollector interface {
Emit(ctx context.Context, kind string, payload any, keysAndValues ...string) error
}
Honest note: the original blog's snippets about
experiences_1024table names and the exact code walk ofStoreDistilledTaskthrough WriteBuffer I did not re-derive line-by-line from the currentmanagerlayer here. The internal write details live inproduction_manager.go/production_manager_tasks.go; this article won't re-quote them verbatim.
10. Runtime Evolution: MemoryPatchExecutor
Memory config isn't read-once at startup. MemoryPatchExecutor (memory_patcher.go) turns MemoryConfig into a patch.RuntimeComponent that can evolve at runtime:
PatchChangePlanner→ changemax_history/max_tasks/max_sessionsPatchChangeBudget→ changemax_distilled_tasks/session_ttlPatchChangeReducer→ changeuse_structured_cleaning
It implements validate-before-mutate (a bad patch leaves config untouched) and returns a rollback-able RuntimePatch (rollback: restore previous memory config). NewMinimalMemoryManager provides a config-only lightweight ProductionMemoryManager for the no-database default bootstrap path (its dbPool/embeddingClient etc. are nil — config access only).
11. Skill Priors: Task Pattern → Relevance (Related But Not "Distillation")
The third thing named "experience" is in internal/runtime/protocol/skills. It's not the same as distillation above — this is a Learned Source that only biases future skill-discovery ranking, never auto-invokes a skill.
Experience.Record(skill, taskPattern, successRate): stores {skill, task pattern, success rate}; re-recording the same (skill, pattern) pair replaces its rate; count is capped atmaxRecords=1000, oldest evicted when exceeded.Experience.BestMatch(taskPattern): scores by keyword overlap (short patterns use containment; long patterns tokenize and score by overlap ratio, thresholdmatchScoreThreshold=0.5), returning the highest-success-rate prior.- Optional persistence:
JSONExperienceStorewrites atomically (temp file + rename);NewExperienceWithStorepreloads at startup. ExperienceConfidenceSource: adaptsExperiencetotaskfabric.ConfidenceSource;Confidence(taskPattern)returns the best prior'sSuccessRate(0 when none). This is the piece that actually reaches the scheduler side — driven by real learned priors, not hardcoded constants.
If your interest is "how memory priors affect scheduling," this is the path that lands on the scheduler, whereas the ares_experience ranking/conflict machinery serves GA guidance and spawn injection.
12. Things You Might Expect — Marked Clearly
Let me be blunt about several things I did not find in the code (things intuition / the old blog might assume "should be there"):
- "Requires ≥2 non-failure trajectory supports to graduate" —(待核实 / not found). I grepped
ares_experienceandares_memory/distillationfor graduate / promote / vote / minSupport — no such implementation;ShouldDistillonly checks length. If any "graduation" logic exists elsewhere (e.g.knowledge/orflight/), it belongs to a different system. - "Candidate knowledge and formal knowledge stored separately" —(待核实 / not found). Experiences/memories all go into the unified ExperienceRepository; I saw no candidate-vs-formal split-table design.
- "Original three-layer memory + event-sourcing cognitive recovery" — partially unverified.
MemoryManagerdoes split session/task blocks andSetEventStoredoes exist, butmanager.gohas noexperiences_1024-style table details, and theRecoverAgentState / buildCognitiveStatestory belongs to agentfabric (another article). Trust the files cited here. - "How many tokens distillation saves / 22.3% deviation / GPT-5.5 / sensenova" tables — NOT adopted. Those are numbers I couldn't trace to a source in this repo and couldn't reproduce — I won't fake an "LLM-verified 96% savings" conclusion in front of you. (A benchmark test file
distillation/benchmark_token_comparison_test.godoes exist, but this article won't invent a "verified savings" figure.)
Conclusion: The Real Data Flows
Linking the three tracks, ares "accounts" for memory like this:
TaskResult
→ (EventTaskCompleted/Failed) → DistillationService.Distill
→ ShouldDistill guard → LLM extraction of Problem/Solution/Constraints
→ store Experience (Type success/failure)
→ default sync embed, or async queue vector backfill
→ FeedbackService: success count +1 / failure Score down
→ RankingService: FinalScore = Semantic + UsageBoost + RecencyBoost + Score
→ ConflictResolver: group at 0.9 similarity, keep highest per group
→ downstream: GA guidance (Hints/Record) + Spawn injection (ExperiencePrior → CognitiveState.Context)
Messages
→ (ConversationSource→Pipeline→Distiller.DistillConversation)
→ extract → classify(knowledge/preference/interaction/profile) → embed → resolveConflicts → finalTopN
→ optional PushRelevant + report generation (reportSink)
taskPattern
→ ares_skills.Experience.Record(skill, pattern, successRate)
→ BestMatch → ExperienceConfidenceSource.Confidence → scheduler/downstream
Honest wrap-up: four things in this code are genuinely solid — event-driven task-level experience distillation, bandit feedback with multi-signal weighted ranking, lazy conflict grouping, and the two experience feedback channels (spawn + GA). As for "how many trajectories it takes to graduate," "candidate vs formal split stores," and "how many tokens saved," I couldn't verify those, so I'm not going to dress them up for you here.
Next Article Preview: Workflow Engine — in 0.3.x the DAG is the scheduling source directly, MutableDAG adds/removes nodes at runtime (GraphPatchExecutor), with thread-safe cycle detection and checkpoint recovery. Same approach: only code I can verify.