@mattpocockuk on the similar lanes this is quite effective google-research/harness2 , been using this alot these days
21 likes · 5,757 views
Harness : quand l'agent améliore lui-même le harness qui l'entoure
Quand un agent déçoit, le premier réflexe est de changer de modèle. Pourtant, un même modèle peut passer de décevant à convaincant selon son prompt système, les outils qu’on lui décrit ou sa façon de relire son travail avant de rendre un livrable. Cet environnement a un nom : le harness. Google Research vient de publier en open source Harness², un framework qui le fait évoluer automatiquement, sans toucher aux poids du modèle. Le papier et le site du projet détaillent la méthode, les chiffres et les limites.
> TL;DR
>
> - Harness² optimise le harness d’un agent (prompt, mémoire, outils, sous-agents…) au lieu du modèle.
> - Deux boucles : une par tâche (Propose, Probe, Compose), une qui apprend de tâche en tâche.
> - Selon Google, une seule étape suffit à améliorer GLM-5 et Gemini 3.5 Flash sur trois benchmarks. Le coût en calcul et la vérification sans réponse de référence restent les points à surveiller.
Nous verrons ce qu’est un harness, comment fonctionnent les deux niveaux de récursion, puis nous lancerons la démo locale avant de regarder les limites.
Le harness : tout ce qui entoure le modèle
Un modèle seul ne fait que produire du texte. Ce qui en fait un agent, c’est le système autour : prompts, outils, mémoire, orchestration. Harness² en expose huit composants modifiables :
- Construction du contexte : prompt système, garde-fous, mémoire, skills
- Interface d’outils : consignes d’utilisation des outils, scripts
- Orchestration : sous-agents
- Middleware : plugins
!Les huit composants du harness regroupés par rôle à l’exécution : contexte, outils, délégation et middleware
L’optimisation de ce système s’appelle le _harnessing_. Les équipes qui maintiennent un AGENTS.md ou des skills à la main font déjà cette boucle : observer un échec, réécrire une consigne, relancer. Harness² la confie à des agents.
Premier niveau : Propose, Probe, Compose
Pour la tâche en cours, le framework enchaîne trois étapes :
1. Propose : un éditeur analyse une première exécution et propose des modifications du harness. Il en existe deux familles : des règles générales de comportement (par exemple relire un fichier après l’avoir écrit) et des conseils propres au domaine de la tâche.
2. Probe : chaque variante est exécutée sur la même tâche.
3. Compose : un composeur compare les trajectoires obtenues (appels d’outils, observations, fichiers produits) et assemble un harness final à partir des changements dont l’effet est observable.
Aucune réponse de référence n’est utilisée : le composeur s’appuie uniquement sur la comparaison des comportements entre exécutions.
!Pipeline de Harness² : l’exécuteur lance le harness initial, l’éditeur propose des modifications, les sondes les testent et le composeur produit le harness final
Second niveau : apprendre à améliorer
Sans mémoire, chaque tâche repartirait de zéro. Après chaque tâche, Harness² conserve donc deux choses dans un _improvement harness_ persistant : le harness qui a été adopté, et les leçons que les traces d’exécution permettent d’appuyer (par exemple, relire le fichier final avant de le rendre). Ces leçons guident les propositions faites sur les tâches suivantes.
En revanche, une nouvelle tâche ne reprend pas le harness spécialisé de la précédente : elle repart du harness de base. Un réglage pensé pour analyser un contrat pourrait gêner sur une tâche de tableur, donc le framework garde ce qu’il a appris, pas la configuration elle-même.
Parallèle ou séquentiel
Le budget de récursion se règle avec --k, de deux façons :
| Mode | Comportement |
| --- | --- |
| --mode parallel | Teste K candidats généraux et K candidats de domaine, puis produit K compositions |
| --mode sequential | Enchaîne K étapes Propose, Probe, Compose, en reportant le harness affiné et les preuves |
Le mode parallèle explore plus large, le séquentiel affine plus en profondeur. Dans les deux modes, dès que K dépasse 1, un sélecteur compare à l’aveugle les livrables obtenus et n’en garde qu’un, sans savoir de quel harness il vient.
!Les deux modes de récursion : parallèle avec k candidats testés simultanément, séquentiel avec k étapes successives
Essayer en local
La démo ne demande aucun identifiant de modèle : elle lance les deux modes avec des éditeurs déterministes et de vraies exécutions Python. Le dépôt indique Linux et Python 3.12 comme prérequis, mais la démo a aussi tourné sur macOS lors de nos essais.
# Environnement Python 3.12 avec uv
uv venv --python 3.12 .venv
source .venv/bin/activate
# Installation du projet depuis la racine du dépôt cloné
uv pip install -e '.[render]'
# Démo locale, budget de récursion k=2
harness2-demo --output runs/demo --k 2
Pour les benchmarks, Harness² ne remplace pas votre agent : il le pilote. Cet agent s’appelle l’exécuteur. C’est OpenCode par défaut, ou Codex (l’agent de code d’OpenAI) avec l’option --substrate cx. Il faut aussi accéder à des modèles via Vertex AI, et installer Node.js 20+ et gcloud. L’exemple du README lance une tâche du benchmark LAB, puis l’évalue et produit le rapport :
harness2-setup lab
harness2-check --bench lab
harness2 --bench lab --k 1 --mode parallel --only-domains antitrust-competition --max-tasks 1
harness2-judge --bench lab --k 1 --mode parallel
harness2-report --bench lab --k 1 --mode parallel
Ces runs consomment des appels API : le README recommande de commencer par une petite sélection. Attention, --max-tasks plafonne le nombre de tâches par domaine, pas sur l’ensemble du run.
Résultats annoncés et limites
Google a évalué Harness² sur trois benchmarks : JobBench (travail professionnel), LAB (tâches juridiques) et WorkBuddy-Bench (bureautique, web et code). Selon ses auteurs, une seule étape améliore GLM-5 et Gemini 3.5 Flash par rapport à leur harness de base : jusqu’à +11,5 points sur WorkBuddy-Bench, +4,5 sur JobBench et +11,8 points de pourcentage sur le taux de réussite partielle de LAB. Un budget de récursion plus élevé (un --k plus grand) apporte des gains supplémentaires.
Deux détails pèsent dans la lecture de ces résultats, détaillés dans le papier. L’éditeur et le sélecteur sont Claude Opus 4.8, et le juge de JobBench aussi : un biais est possible. Les échantillons sont petits : 34 tâches de test pour JobBench et 100 pour LAB, avec des scores moyennés sur 3 runs.
Le calcul d’abord : chaque proposition, chaque sonde et chaque composition est une exécution de plus de l’agent, donc des tokens et du temps. Plus --k est élevé, plus la facture grimpe.
Le choix du meilleur candidat ensuite. Aucune bonne réponse n’est connue pendant l’exécution : le composeur s’appuie sur le comportement observé et, dès que --k dépasse 1, le sélecteur final choisit sans connaître la bonne réponse. Il peut se tromper. Les auteurs relèvent eux-mêmes des régressions : sur une tâche de code de WorkBuddy-Bench, le sélecteur a retenu une sortie notée 55 alors qu’une autre atteignait 95.
La sécurité enfin. Garde-fous, scripts et plugins font partie du harness, donc l’éditeur automatique peut les modifier. Côté exécution, l’agent a un accès réseau sans restriction, et l’exécuteur Codex tourne sans sandbox. Le README demande de restreindre le réseau avant toute évaluation, car les critères de notation des benchmarks peuvent se trouver en ligne.
Reste le statut du projet : le README précise que ce n’est pas un produit officiellement supporté par Google. À traiter comme un projet à expérimenter avant de l’envisager en production.
Et ensuite ?
Harness² propose de traiter le harness comme un ensemble de fichiers que l’on peut lire et mesurer, plutôt que comme un réglage fait une fois à la main. Son point faible est la vérification : sans bonne réponse connue, rien ne garantit qu’un harness « amélioré » le soit vraiment. Pour tester sans risque, une équipe peut garder un harness de base figé, laisser les révisions se faire dans un environnement isolé, puis comparer la qualité obtenue au calcul dépensé.
🎓 Aller plus loin avec les agents
Vous voulez construire votre propre agent IA ? En une journée, la formation Développer son propre agent de TechTown couvre :
- la différence entre un agent et un chatbot ;
- l’architecture d’un agent : sessions, mémoire, grounding et MCP ;
- les tests, les guardrails et le déploiement ;
- trois ateliers pour créer un agent avec ADK, lui ajouter des tools et le brancher à un serveur MCP.
Google Research built agents that rewrite their own prompts, tools and memory without touching model weights, gaining up to 11.8 points on pro benchmarks. 🔗 google-research/harness2
Credits: google-research/harness2
#harness2 #python #aiagents
2 likes
[Agent Platform in Action (7)] When ADK Go Graph Workflow Meets Google Research Harness (Part 1): Two-Layer Recursive Agent Fram
!Harness² Part 1 Architecture: Current Architecture vs c1..c8 Editable Harness Tree
Foreword: After the AI Agent Goes Online, What is the Next Bottleneck?
Welcome back to the "Agent Platform in Action" series! In the previous six articles, we took the LINE AI Card Assistant (kkdai/line-card-assistant) through the complete five stages from a standalone prototype to production-grade cloud deployment:
1. [[Agent Platform in Action (1)] Building LINE AI Card Assistant with Go and Google ADK 2.0 Graph Workflow](/2026/10/16/adk-graph-workflow-go/): Using google.golang.org/adk/v2/workflow to build a 13-node directed graph featuring Vision OCR, AddFanOut / AddFanIn parallel background investigation, and NewRequestInputEvent HITL interaction loops.
2. [[Agent Platform in Action (2)] From Monolithic Cloud Run to Google Cloud Agent Runtime](/2026/10/17/adk-graph-workflow-go-2/): Completed Phase 1 (PR #10), standardizing the project into an agents-cli (adk_go) dual-channel architecture.
3. [[Agent Platform in Action (3)] Implementing Cross-Instance Business Card Recognition and Pause/Resume with ADK 2.0 GCS Artifact Service and Agent Platform Sessions](/2026/10/20/adk-graph-workflow-go-3/): Completed the first half of Phase 2 (PR #11), solving the HITL "amnesia" of stateless containers and the issue of binary image data explosion.
4. [[Agent Platform in Action (4)] Completing the Architecture Puzzle with Cloud Run LINE Webhook Gateway, Agent Runtime, and Cloud SQL PostgreSQL](/2026/10/21/adk-graph-workflow-go-4/): Completed the second half of Phase 2 & Phase 3 (PR #12), upgrading the business card database to Cloud SQL PostgreSQL and achieving two-layer stateless decoupling.
5. [[Agent Platform in Action (5)] Establishing Automated Evaluation Benchmarks with agents-cli eval and ADK 2.0 Go](/2026/10/22/adk-graph-workflow-go-5/): Completed Phase 4 (PR #13), establishing a golden test set and a quality flywheel.
6. [[Agent Platform in Action (6)] Building a Full-Stack Observable LINE AI Card Assistant with agents-cli deploy, Terraform IaC, GitHub Actions CI/CD, and Cloud Trace](/2026/10/24/adk-graph-workflow-go-6/): Completed Phase 5 (PR #14), adding OpenTelemetry Spans (workflow.node.*) to all 13 Graph Nodes and deploying to the cloud via Terraform and GitHub Actions.
Additionally, we have planned advanced Graph Workflow primitives for future ADK v2.6+ (such as Parallel dynamic dispatch, MultiRoute multi-way fan-out, reflection-repair loops, and strongly typed nodes) into the Future Roadmap Issue #15.
However, now that our Agent possesses Phase 4 Automated Evaluation and Phase 5 Full-Node Tracing (OpenTelemetry Spans), a deeper question emerges:
> When evaluations find that the Agent performs poorly in certain real-world cases (e.g., Japanese-English bilingual cards, searching for colloquial nicknames in the Taiwan tech industry, multi-clause colloquial HITL modifications, or financial compliance icebreaker letters), must engineers manually check logs, manually modify Prompt strings in the Go source code, and then recompile and redeploy every single time?
Can we let a Meta-Agent (Engineer Agent) automatically read our evaluation results and OpenTelemetry execution trajectories, diagnose the reasons for lost points, write new Skills and Long-Term Memory itself, and complete a closed-loop self-evolution?
This is precisely the core proposition addressed by Google Research's recently published and open-sourced google-research/harness2 (_Harness²: Recursive Harnessing for LLM Agents_, arXiv:2610.14224)!
This series will consist of three articles (corresponding to Issue #16, Issue #17, Issue #18, and PR #19), guiding you through the complete integration of Google Research Harness² into our Go ADK 2.0 project:
- Part 1 (This article, Issue #16): What is Google Harness²? What can it do? Inventorying the existing architecture before modification and decoupling hardcoded configurations in the Go binary into a c1..c8 editable Harness file tree.
- Part 2 (Issue #17): Encapsulating Phase 4's eval/datasets/ and Phase 5's OpenTelemetry Spans into a LineCardAdapter for Harness², and analyzing why "Zero-Leakage Barriers" and "High-Resolution Behavioral Trajectories" are both indispensable.
- Part 3 (Issue #18): Complete testing tutorial, disclosure of Parallel vs Sequential + LongTermMEMORY two-layer recursive experimental data (accuracy jumping from 62.5% to 100.0%), and outlook on the future development of Agents.
- * *
1. What is Google Research Harness²? What can it do?
1. From "Fine-tuning Model Weights" to "Optimizing Agent Harnessing"
In the era of Large Language Models (LLMs), the success of an AI Agent is often determined not just by the underlying model (like Gemini 2.5 Flash / Pro) itself, but by the "Agent Harness" layer wrapped around the model—namely, System Prompts, formatting and safety Guardrails, Long-Term Memory, Tool Descriptions, Sub-Agent settings, and On-Demand Skills.
Past automated prompt optimizations (like PromptBreeder, TextGrad, or DSPy) mostly targeted mutations of a single Prompt string. However, in a real enterprise-grade Agent, if you stuff all edge-case patching rules into the same System Prompt, it quickly leads to "Context Bloat": the number of Always-On Tokens increases, which not only squeezes the model's Output Token Budget but also causes different rules to interfere with each other!
Google Research's Harness² (_Recursive Harnessing for LLM Agents_, arXiv:2610.14224) breaks this limitation. It defines the entire Agent's equipment as a structured c1..c8 Editable Component Tree and automatically evolves this file tree through Two-Level Recursion:
flowchart TB
subgraph Outer["Outer Recursion: Harnessing-Level Recursion (Experience accumulation across rounds k=1..K)"]
Reflector["Experience Reflector\n(ReflectorPrompt)\nDistilling success and failure patterns across rounds"]
Memory["c3: LongTermMEMORY.md\nPersisting domain experience across iterations"]
Reflector -->|Write long-term rules| Memory
end
subgraph Inner["Inner Recursion: Task-Level Recursion (Closed-loop diagnosis and patching within a single round)"]
Propose["1. Propose (Diagnostic Proposal)\nRead trajectory.md\nLocate behavioral defects and propose N candidate Patches"]
Probe["2. Probe (Sandbox Probing)\nExecute N candidate Harnesses in parallel\nCompare trajectory and score changes before and after modification"]
Compose["3. Compose (Conflict Resolution and Synthesis)\nRetain positive-gain Patches, discard negative interference\nSynthesize the next-generation best Harness H^(k)"]
Propose --> Probe --> Compose
end
Memory --> Propose
Compose --> Reflector
2. The c1..c8 Eight Harness Components and the 24KB Budget Limit
In google-research/harness2/harness.py, a standard Agent Harness consists of 4 Always-On Components (c1..c4) and 4 On-Demand Components (c5..c8):
| Component Code | File Path Rule | Loading Mode | Responsibility in Harness² |
| --- | --- | --- | --- |
| c1 | systemprompt.md | Always-On | Defines core roles, node responsibilities, and indexing rules for triggering c6 skills (Skill Trigger Lines). |
| c2 | guardrails.md | Always-On | Defines non-violable output formats (e.g., pure JSON constraints), E.164 normalization invariants, and security/PII guardrails. |
| c3 | LongTermMEMORY.md | Always-On | Domain lessons and industry compliance memories written across rounds by the outer Experience Reflector. |
| c4 | tool_descriptions/*.md | Always-On | Tool parameter descriptions, search strategies, and normalization details (e.g., Japan long-distance prefix (0) cleaning rules). |
| c5 | sub_agents/<name>/AGENT.md | On-Demand | Exclusive Instructions and YAML Frontmatter (mode: subagent) for delegated sub-agents (e.g., company_enrich). |
| c6 | skills/<name>/SKILL.md | On-Demand | Professional skills dynamically injected only for specific domains or tasks (e.g., Japanese card OCR, Hsinchu Science Park colloquial search expansion). |
| c7 | scripts/*.py | On-Demand | Auxiliary verification or data conversion scripts. |
| c8 | plugin/*.ts | On-Demand | Runtime lifecycle and OpenTelemetry diagnostic hooks. |
> [!IMPORTANT] Why distinguish between c1..c4 (Always-On) and c5..c8 (On-Demand)? Harness² sets an iron rule in OPENCODE_SURFACE: The total byte count of c1..c4 always-on files must never exceed 24,000 bytes (AlwaysOnMaxBytes = 24000)! When Harness² finds that a failure case requires lengthy domain knowledge (e.g., a department title mapping table for Japanese corporate cards or a colloquial abbreviation dictionary for Taiwan semiconductor parks), it won't force these rules into systemprompt.md causing Context explosion. Instead, it will automatically create an on-demand c6 skill file (skills/<name>/SKILL.md) and leave only a lightweight Trigger Line in systemprompt.md!
3. How Does Two-Level Recursion Work?
The Harness² evolution engine (harness2/core/recursion.py) includes two levels of recursion:
1. Level 1 (Inner): Task-Level Recursion (Propose $\rightarrow$ Probe $\rightarrow$ Compose):
- Propose (Diagnostic Proposal): The optimizer (Harvester LLM) observes tasks that lost points in the benchmark, the final output (Deliverable), and the execution trajectory (trajectory.md). It diagnoses which step the Agent made a wrong decision on and proposes multiple independent candidate patches (Candidate Patches, each being a set of additions or modifications to c1..c8).
- Probe (Sandbox Probing): The system mounts each candidate Patch into a sandbox and runs the task once, collecting new scores and new execution trajectories to form a "Before vs. After" Behavioral Contrast.
- Compose (Conflict Resolution and Synthesis): The optimizer compares the actual performance of all candidate Patches, merges modifications that truly bring positive score improvements into the new generation Harness $H^{(k)}$, and discards ineffective or side-effect-inducing modifications.
2. Level 2 (Outer): Harnessing-Level Recursion (Experience Reflector):
- Between multiple iterations ($k = 1 \dots K$), the outer ReflectorPrompt reviews the evolution history from $H^{(0)} \to H^{(1)} \to \dots \to H^{(k)}$: Which types of modifications succeeded? Which cross-domain constraints (e.g., financial industry compliance headers and personal data protection regulations) need to be remembered at a global level?
- Distilled high-level experiences are written into c3: LongTermMEMORY.md, guiding the next round of Propose and Agent execution.
- * *
2. Inventorying the Current Architecture: Why Phase 1~5 Go Implementation Cannot Be Directly Optimized by Harness²?
Let's compare the left side (Before: Hardcoded Go Binary Architecture) of the architecture diagram at the beginning of the article and review the system architecture we completed in Phase 1 to Phase 5:
flowchart LR
subgraph Before["Before: Phase 1~5 Solid Compilation Architecture (Hardcoded Go Binary)"]
direction TB
N1["nodes_ingest.go\nHardcoded VisionOCR / CleanAndDedup /\nCompanyEnrich / ApplyEdit Prompts"]
N2["nodes_search.go\nHardcoded QueryIntent /\nEmailDraft Prompts"]
N3["graph.go\nFixed SubAgent Instruction"]
E["eval/datasets/basic-dataset.json\nPhase 4 Golden Test Set"]
T["telemetry.go\nPhase 5 OpenTelemetry 13-Node Spans"]
end
Problem["Pain Points:\n1. Prompts hardcoded in .go string constants; re-go build required for every adjustment\n2. Lack of on-demand c6 Skills mechanism\n3. Phase 4 Eval and Phase 5 OTel Spans not yet connected into an automated optimization loop"]
Before --> Problem
In the code before modification:
1. All Prompt templates for VisionOCRNode, CompanyEnrichNode, ApplyEditNode (located in internal/workflow/nodes_ingest.go), as well as QueryIntentNode and EmailDraftNode (located in internal/workflow/nodes_search.go), are hardcoded as fmt.Sprintf(...) string constants in the Go code.
2. The llmagent.Config{Instruction: ...} for CompanyEnrichAgent (located in internal/workflow/nodes_ingest.go) is also a fixed string hardcoded in a Go function.
3. When we want the Harness² Python evolution loop to modify system prompts, add a Japanese business card recognition skill (skills/jp_card_ocr/SKILL.md), or adjust phone number normalization guidelines (tool_descriptions/tool_guidance.md), it is impossible for Harness² to rewrite our .go source code and go build again for every single task!
Therefore, the first step in introducing Harness² (Issue #16) is to completely decouple the Go compilation phase from the Harness equipment phase:
- Go Binary is responsible for "Graph Topology and Dual-Channel Execution Engine": RouterNode, parallel Fan-Out/Fan-In, HITLConfirmNode pause/resume, Cloud SQL and GCS Artifact access, OpenTelemetry instrumentation.
- External c1..c8 Harness file tree is responsible for the "Evolvable Brain and Skill Equipment": Dynamically hot-reloaded at runtime via the environment variable LINE_CARD_HARNESS_DIR!
- * *
3. Implementation Part 1: Building internal/harness Loader and harness/base/ Initial Equipment Tree
To make the Go ADK 2.0 Graph Workflow natively understand the Harness² c1..c8 specification, we added two core directories to the project:
- harness/base/: Stores the initial unevolved ($k=0$) c1..c8 baseline file tree.
- internal/harness/harness.go: Responsible for loading the c1..c8 file tree, validating OPENCODE_SURFACE 24KB limits and YAML Frontmatter rules, and dynamically combining always-on context with on-demand skills for each Graph Node.
1. Establishing the harness/base/ Baseline c1..c8 File Tree
We extracted the basic settings previously scattered in nodes_ingest.go and nodes_search.go into the harness/base/ directory:
harness/base/
├── systemprompt.md # c1: Core roles and overview of Graph Node responsibilities
├── guardrails.md # c2: JSON output format and phone normalization guardrails
├── LongTermMEMORY.md # c3: Cross-round long-term memory (initially empty, to be written by Reflector)
├── tool_descriptions/
│ └── tool_guidance.md # c4: Guidance for google_search and format_company_profile
├── sub_agents/
│ └── company_enrich/
│ └── AGENT.md # c5: CompanyEnrichAgent delegated sub-agent definition
├── skills/ # c6: On-demand skills directory (to be generated by Propose-Probe-Compose evolution)
├── scripts/ # c7: Auxiliary scripts directory
└── plugin/ # c8: Diagnostic plugin directory
For example, harness/base/sub_agents/company_enrich/AGENT.md uses a YAML Frontmatter format fully compatible with google-research/harness2:
---
mode: subagent
description: Searches Google to summarize company background and industry tags.
---
You are a professional business analysis assistant. Please use the google_search tool to query the company name provided by the user, then call the format_company_profile tool to return a two-sentence Traditional Chinese company profile and 3 to 5 technical or industry tags (e.g., AI, Cloud, SaaS, FinTech, Semiconductor).
2. Implementing internal/harness/harness.go: 24KB Always-On Budget Validation and On-Demand Skill Injection
In internal/harness/harness.go, we implemented the validator Validate() and node prompt synthesizer ComposeNodePrompt() fully aligned with the Python version of OPENCODE_SURFACE.validate():
// AlwaysOnMaxBytes matches google-research/harness2 OPENCODE_SURFACE.always_on_max (24,000 bytes).
const AlwaysOnMaxBytes = 24000
// AlwaysOnFiles lists the four c1..c4 always-on files mounted into the agent context.
var AlwaysOnFiles = []string{
"systemprompt.md",
"guardrails.md",
"LongTermMEMORY.md",
"tool_descriptions/tool_guidance.md",
}
// Validate enforces Google Research Harness² OPENCODE_SURFACE invariants on the c1..c8 tree.
func (h *Harness) Validate() error {
var problems []string
for _, reqFile := range AlwaysOnFiles {
if strings.TrimSpace(h.Files[reqFile]) == "" {
problems = append(problems, fmt.Sprintf("missing or empty always-on component %q", reqFile))
}
}
if total := h.AlwaysOnBytes(); total > AlwaysOnMaxBytes {
problems = append(problems, fmt.Sprintf(
"always-on c1..c4 byte count %d exceeds cap %d (every token here competes with the agent's output budget)",
total, AlwaysOnMaxBytes,
))
}
combinedAlwaysOn := h.SystemPrompt + "\n" + h.Guardrails
for dirName, skill := range h.Skills {
if skill.Name != dirName {
problems = append(problems, fmt.Sprintf("%s frontmatter name %q must match directory name %q", skill.RelPath, skill.Name, dirName))
}
if strings.TrimSpace(skill.Description) == "" {
problems = append(problems, fmt.Sprintf("%s must include a non-empty description in YAML frontmatter", skill.RelPath))
}
if !strings.Contains(combinedAlwaysOn, dirName) {
problems = append(problems, fmt.Sprintf("skill %q is never referenced in systemprompt.md or guardrails.md; add a trigger line naming `%s`", dirName, dirName))
}
}
// ...
}
There are two very critical engineering details here:
1. Mandatory Skill Trigger Reference Check: If Harness² adds a skill in skills/jp_card_ocr/SKILL.md but forgets to write when to trigger jp_card_ocr in systemprompt.md or guardrails.md, Validate() will directly report an error and intercept it, avoiding the creation of "Orphan Skills" that will never be called.
2. Node-Level Dynamic Skill Matching (MatchSkills + ComposeNodePrompt): When the Graph Workflow executes VisionOCRNode, CompanyEnrichNode, ApplyEditNode, QueryIntentNode, or EmailDraftNode, it only injects c6 skills related to that node and automatically attaches adk.harness.skills and adk.harness.always_on_bytes attributes to the OpenTelemetry Span:
// composeHarnessPrompt enriches a node's base prompt with the active Harness² c1..c8 tree
// and records the active harness metadata onto the node's OpenTelemetry span.
func composeHarnessPrompt(span trace.Span, nodeName, basePrompt, taskInput string) (string, *harness.Harness) {
h := harness.LoadActive()
composed, skills := h.ComposeNodePrompt(nodeName, basePrompt, taskInput)
if span != nil {
span.SetAttributes(
AttrHarnessRoot.String(h.RootDir),
AttrHarnessAlwaysOnBytes.Int(h.AlwaysOnBytes()),
)
if len(skills) > 0 {
span.SetAttributes(AttrHarnessSkills.StringSlice(skills))
}
}
return composed, h
}
We can directly use the CLI to verify the always-on byte count and structural validity of harness/base:
$ go run ./cmd/harness-runner --validate-only --harness-dir=harness/base
OK (1599 always-on bytes)
- * *
4. Summary and Next Article Preview
In this article (Part 1), we completed the goals of Issue #16:
1. Gained a deep understanding of how Google Research Harness² (arXiv:2610.14224) automatically evolves Agent capabilities through a c1..c8 editable file tree and two-level recursion (Task-level Propose-Probe-Compose + Harnessing-level Experience Reflector), without recompiling the main program or bloating the always-on context.
2. Inventoried the architectural bottlenecks of Phase 1~5 where prompts and normalization logic were hardcoded in the Go binary.
3. Implemented internal/harness/harness.go and harness/base/, allowing the 13 nodes of the ADK 2.0 Go Graph Workflow to support dynamic mounting of the c1..c8 equipment tree via LINE_CARD_HARNESS_DIR.
With a hot-reloadable c1..c8 equipment tree in place, the next article [[Agent Platform in Action (8)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 2): Turning Phase 4 Eval Datasets and Phase 5 OpenTelemetry Spans into LineCardAdapter](/2026/10/26/adk-graph-workflow-go-8/) will enter Issue #17: revealing why we need to encapsulate Phase 4 test sets and Phase 5 OpenTelemetry Spans into a LineCardAdapter, and how the "Zero-Leakage Barrier" prevents the AI optimizer from cheating!
1 reaction
[Agent Platform in Action (8)] When ADK Go Graph Workflow Meets Google Research Harness (Part 2): Integrating Phase 4 Eval Datas
!Harness² Part 2: LineCardAdapter Bridging Phase 4 Eval & Phase 5 OpenTelemetry Spans
Introduction: Why Decoupling Prompts Isn't Enough?
In the previous post [[Agent Platform in Practice (7)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 1): Understanding the Recursive Agent Framework and c1..c8 Decoupling](/2026/10/25/adk-graph-workflow-go-7/) (corresponding to Issue #16), we introduced Google Research's google-research/harness2 (arXiv:2610.14224) and decoupled the prompts, guardrails, long-term memory, and on-demand skills of the LINE AI Business Card Assistant (kkdai/line-card-assistant) into an editable c1..c8 Harness file tree that can be dynamically mounted via LINE_CARD_HARNESS_DIR.
However, simply having a hot-reloadable c1..c8 file tree isn't enough; the Harness² dual-layer recursive optimization engine (harness2/core/recursion.py) still cannot directly optimize our Go Agent. This is because Harness² requires a standardized bridge — the BenchmarkAdapter — to answer three questions:
1. list_tasks() (What are the test questions?): What test cases should the Agent challenge? What are the public inputs (Prompt / Image) for each case, and what are the hidden scoring criteria (Rubrics)?
2. run() (How does the Agent think and execute?): When a candidate Harness $H’$ is mounted to execute a specific task, what is the Agent's final output (deliverable)? More importantly, what decision trajectory did it follow across the 13 nodes of the Graph Workflow (trajectory.md)?
3. score() (How was the performance?): Which independent scoring items (per_rubric) did the final output and execution trajectory pass, and where did it lose points?
Conveniently, in Phase 4 (PR #13), we already established the eval/datasets/ automated evaluation benchmark, and in Phase 5 (PR #14), we added workflow.node.* OpenTelemetry Spans to all 13 Graph Nodes!
In this post (Part 2, corresponding to Issue #17 and PR #19), we will merge these two existing assets into the Harness² LineCardAdapter and explain in depth "Why must it be designed this way?".
---
I. Why This Change? Two Core Design Philosophies
Before diving into the code, many engineers often ask two questions:
1. _"Why not just feed the entire eval/datasets/*.json file to the optimizer LLM and let it modify the Prompt itself?"_
2. _"Why do we need to convert Phase 5 OpenTelemetry Spans into trajectory.md? Isn't the final LINE reply text and total score enough?"_
These two questions touch upon the most core engineering designs in the Harness² paper: the Zero-Leakage Barrier and Behavioral Trajectory Contrast.
1. Why the "Zero-Leakage Barrier"? Preventing the Optimizer from "Cheating by Memorizing Answers"
Imagine this: if we expose the golden answers in eval/datasets/harness2-challenge-dataset.json (e.g., expected_contains: ["+81-3-1234-5678", "Kenta Sato"]) directly to the Agent or the Harness² Harvester LLM, what happens?
The laziest and easiest way for the optimizer to get a perfect score is to write a single line in systemprompt.md:
> _"If the input contains Sato, please directly output +81-3-1234-5678 and Kenta Sato."_
In machine learning, this is known as Test Set Leakage / Overfitting to Rubrics. The Agent appears to score 100 on the benchmark, but it crashes immediately when it encounters a new Japanese business card in the real world!
flowchart LR
Dataset["eval/datasets/*.json\nComplete Test Cases\n(incl. expected_contains / expected_route)"]
subgraph Barrier["Zero-Leakage Barrier"]
Brief["TaskBrief (Public Task Sheet)\nContains only task_id, prompt, session_input\n❌ Never contains expected_* golden answers"]
Scorer["Isolated Scorer (Black-box Grader)\nHolds independent hidden r1..r4 Rubrics"]
end
Agent["Go ADK Graph Workflow\n(cmd/harness-runner)"]
Harvester["Harness² Harvester\n(Propose / Probe / Compose)"]
Dataset --> Brief
Dataset --> Scorer
Brief -->|"Public Input"| Agent
Agent -->|"Deliverable + OTel trajectory.md"| Scorer
Scorer -->|"Returns only binary scores {r1: 1.0, r2: 0.0}\nand OTel execution trajectory"| Harvester
In the Harness² BenchmarkAdapter architecture:
- TaskBrief (Public Task Sheet): Can only contain user input (prompt and simulated images/context). It is strictly forbidden from containing any expected_contains, expected_not_contains, or correct answer strings.
- score() (Black-box Grader): After comparing answers in an isolated environment, it only returns "which Rubric received a 1.0 or 0.0" (e.g., r3_e164_jp_phone_normalization: 0.0), without leaking the expected string to the optimizer.
- As a result, the Harness² optimizer must truly reason out general domain rules (e.g., "When converting Japanese phone numbers +81 (0)3-... to E.164, the domestic trunk prefix (0) must be removed") by reading the Agent's execution trajectory (trajectory.md), rather than hard-coding answers into the Prompt!
2. Why Convert Phase 5 OpenTelemetry Spans into trajectory.md? Opening the Black Box of the 13-Node Graph
Our LINE AI Business Card Assistant is not a simple "single LLM call," but an ADK 2.0 Directed Acyclic Graph (Graph Workflow) consisting of 13 nodes:
- Business Card Ingestion Pipeline: RouterNode $\rightarrow$ VisionOCRNode $\rightarrow$ Parallel AddFanOut (CleanAndDedupNode, CompanyEnrichNode, ContextMatchNode) $\rightarrow$ AddFanIn (JoinPreviewNode) $\rightarrow$ BuildPreviewNode $\rightarrow$ HITLConfirmNode $\rightarrow$ ApplyEditNode / SaveCardNode.
- Smart Search and Icebreaker Pipeline: RouterNode $\rightarrow$ QueryIntentNode $\rightarrow$ HybridSearchNode / BroadSearchNode $\rightarrow$ FormatCarouselNode / EmailDraftNode.
Without Phase 5's OpenTelemetry Spans, when a user searches for _"Help me find a Sales Director in Hsinchu Science Park working on AI chips"_ but gets _"No matching business cards found"_ (score 0.25), the optimizer only sees this final failure message and cannot determine:
- Did the RouterNode misjudge the search intent as chat?
- Did the QueryIntentNode fail to expand the colloquial "Hsinchu Science Park" into the database's address Hsinchu, or fail to expand "Sales Director" into Sales Director?
- Was there an error in the SQL query logic of the HybridSearchNode?
When we collect and render Phase 5's OpenTelemetry workflow.node.* Spans into a structured trajectory.md, Harness² can clearly see the input, output, duration, routing decisions, and loaded skills of every Graph Node during the Propose and Probe stages!
---
II. Implementation Part 2: Building the LineCardAdapter in Three Steps
Referring to the architecture diagram at the beginning of this article, we completed the implementation of Issue #17 through three core components:
1. internal/harness/trajectory.go: Mounts an in-memory OpenTelemetry SpanRecorder on the Go side to export the execution details of the 13 Graph Nodes as trajectory.md.
2. cmd/harness-runner/main.go: Provides a deterministic subprocess CLI for the Python Harness² to call, receiving --harness-dir and --task-json, and outputting a JSON containing deliverable, trajectory_markdown, and spans.
3. harness/line_card_adapter.py: Implements the google-research/harness2 BenchmarkAdapter interface, connecting the Phase 4 Dataset with the Go harness-runner.
1. Implementing internal/harness/trajectory.go: Rendering OpenTelemetry Spans as trajectory.md
In Phase 5, our internal/workflow/telemetry.go already calls startNodeSpan(ctx, "VisionOCRNode") in every Graph Node. Now, in internal/harness/trajectory.go, we use go.opentelemetry.io/otel/sdk/trace/tracetest.NewSpanRecorder() to intercept all completed node Spans and transform them into a Markdown trajectory report that the Harness² Harvester LLM is best at reading:
// InstallRecorder registers a new in-memory SpanRecorder as the global OTel TracerProvider
// so every workflow.node.* span emitted by internal/workflow/telemetry.go is captured.
func InstallRecorder() (*TrajectoryRecorder, func()) {
sr := tracetest.NewSpanRecorder()
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
prev := otel.GetTracerProvider()
otel.SetTracerProvider(tp)
rec := &TrajectoryRecorder{recorder: sr, tp: tp}
restore := func() {
_ = tp.Shutdown(context.Background())
otel.SetTracerProvider(prev)
}
return rec, restore
}
At the same time, we added three OpenTelemetry Attributes to internal/workflow/telemetry.go: AttrNodeDecision (adk.node.decision), AttrNodeDiagnostic (adk.node.diagnostic), and AttrHarnessSkills (adk.harness.skills). When TrajectoryRecorder.RenderMarkdown() executes, it produces a high-resolution trajectory.md like this:
# ADK 2.0 Go Graph Workflow Execution Trajectory
- **Task ID** : `challenge_colloquial_tw_search`
- **Harness Root** : `harness/base`
- **Always-On Bytes (`c1..c4`)**: `1599 / 24000`
- **Final Score** : `0.25`
## OpenTelemetry Node Spans (`workflow.node.*`)
### 1. `workflow.node.RouterNode`
- **Node** : `RouterNode` | **Status** : `OK` | **Duration** : `0.02 ms`
- **Decision** : `intent=search (colloquial_query=Help me find a Sales Director in Hsinchu Science Park working on AI chips, I need to contact Taiwan semiconductor clients)`
- **Diagnostic** : `routed search query to QueryIntentNode`
### 2. `workflow.node.QueryIntentNode`
- **Node** : `QueryIntentNode` | **Status** : `OK` | **Duration** : `0.04 ms`
- **Decision** : `parsed_filters={"location":"Hsinchu Science Park","role":"Sales Director","industry":"AI Chips"}`
- **Diagnostic** : `UNEXPANDED_COLLOQUIAL_FILTERS: colloquial Taiwan science park alias 'Hsinchu Science Park' and Mandarin title 'Sales Director' were passed literally without bilingual synonym expansion; add a c6 skill 'tw_semicon_search' with BILINGUAL_SYNONYM_MAP`
### 3. `workflow.node.HybridSearchNode`
- **Node** : `HybridSearchNode` | **Status** : `OK` | **Duration** : `0.03 ms`
- **Decision** : `matched_cards=0 (strict literal filter on location='Hsinchu Science Park' failed against DB address='Hsinchu Science Park (竹科)')`
Please pay attention to the Span records of the 2nd node workflow.node.QueryIntentNode and the 3rd node workflow.node.HybridSearchNode! Through this trajectory, the Propose stage of Harness² can see at a glance: RouterNode was correct, the error was in QueryIntentNode treating "Hsinchu Science Park" as a rigid string to query the English address field!
2. Expanding the Phase 4 Evaluation Set: eval/datasets/harness2-challenge-dataset.json
To fully test Harness²'s ability to retain existing capabilities (preventing catastrophic forgetting) and conquer difficult edge cases, we established 8 tasks (totaling 32 fine-grained Rubrics) in eval/datasets/harness2-challenge-dataset.json:
| Task Category | Task ID | Scenario Description | 4 Independent Scoring Rubrics (r1..r4) |
| --- | --- | --- | --- |
| Baseline (Regression) | baseline_ingest_ming_wang | Standard Traditional Chinese card ingestion (Wang Xiaoming / Taiwan Cloud Tech) | Intent routing, Name/Title extraction, E.164 phone normalization, Industry tag enrichment |
| Baseline (Regression) | baseline_dedup_appier_promotion | Duplicate detection for promotion (Lin Yating Senior $\rightarrow$ VP) | Dedup detection, Title update, Keep new mobile, Company consistency |
| Baseline (Regression) | baseline_search_cloud_summit_ai | Standard keyword search (Cloud Summit + AI) | Search routing, Multiple hits, Tag display, Interactive buttons |
| Baseline (Regression) | baseline_email_google_cloud | General tech follow-up icebreaker (Chen Jianhong / Google Cloud) | Email routing, Recipient/Event mention, Agenda link, Professional closing |
| Challenge (Evolution 1) | challenge_jp_bilingual_ingest | Bilingual JP/EN card OCR & JP phone normalization (Kenta Sato / CTO / +81 (0)3-1234-5678) | r1 Routing, r2 Bilingual name retention, r3 JP +81 remove (0) normalization, r4 JP title bilingual mapping |
| Challenge (Evolution 2) | challenge_colloquial_tw_search | Colloquial Taiwan tech alias search ("Help me find a Sales Director in Hsinchu Science Park") | r1 Routing, r2 Hsinchu Science Park $\leftrightarrow$ Hsinchu bilingual expansion hit, r3 Sales Director $\leftrightarrow$ Sales Director title expansion, r4 Tag completeness |
| Challenge (Evolution 3) | challenge_noisy_multi_edit_hitl | Multi-clause colloquial HITL continuous correction ("Sorry, let me correct that, the title should be Group Senior VP, also change the end of the mobile to 8899, and add GDG DevFest speaker to notes") | r1 HITL Edit routing, r2 Multi-field atomic update (Title), r3 Mobile suffix partial replacement & Note append, r4 Retain original company/email |
| Challenge (Evolution 4) | challenge_pii_safe_executive_followup | Financial executive compliance & PII masking icebreaker (Zhang Yazhu / Cathay Financial, Digital Risk Officer, requires privacy protection and compliance headers) | r1 Email routing, r2 Executive title/scenario, r3 Mobile/Private Email PII Redaction, r4 Financial compliance subject header [Confidential / FinTech Compliance Verified] |
3. Implementing harness/line_card_adapter.py: Connecting to google-research/harness2
Finally, we implemented the LineCardAdapter in harness/line_card_adapter.py, connecting all the above components:
class LineCardAdapter:
"""Adapts `line-card-assistant` to the `google-research/harness2` BenchmarkAdapter protocol."""
def list_tasks(self) -> list[TaskBrief]:
"""Return public task briefs with zero ground-truth rubric leakage."""
briefs: list[TaskBrief] = []
for raw in self._raw_cases:
# Strip `rubrics` and `expected_*` so the agent/harvester never sees ground truth.
briefs.append(
TaskBrief(
task_id=raw["id"],
prompt=raw["prompt"],
context={
"category": raw.get("category", "general"),
"description": raw.get("description", ""),
"session_input": raw.get("session_input", {}),
},
)
)
return briefs
def run(self, harness: Harness, task: TaskBrief) -> RunArtifact:
"""Mount `harness` in a temporary directory and execute the Go Graph Workflow runner."""
problems = harness.validate()
if problems:
diag = "# Harness Validation Failed\n" + "\n".join(f"- {p}" for p in problems)
return RunArtifact(task_id=task.task_id, deliverable="", trajectory=diag)
with tempfile.TemporaryDirectory(prefix=f"line_card_harness_{task.task_id}_") as tmp_dir:
harness_root = Path(tmp_dir)
harness.materialize(harness_root)
# Invoke the compiled Go binary with --harness-dir pointing to the materialized c1..c8 tree
proc = subprocess.run(
[str(binary), f"--harness-dir={harness_root}", f"--task-json={task_payload_path}"],
capture_output=True, text=True, check=False, timeout=30,
)
payload = json.loads(proc.stdout)
return RunArtifact(
task_id=task.task_id,
deliverable=payload.get("deliverable", ""),
trajectory=payload.get("trajectory_markdown", ""),
raw=payload,
)
def score(self, task: TaskBrief, artifact: RunArtifact) -> ScoreResult:
"""Evaluate the deliverable against isolated per-rubric criteria."""
raw = self._cases_by_id[task.task_id]
rubrics = raw.get("rubrics", {})
per_rubric: dict[str, float] = {}
for rubric_id, spec in rubrics.items():
per_rubric[rubric_id] = 1.0 if self._evaluate_rubric(artifact.deliverable, spec) else 0.0
total = sum(per_rubric.values()) / len(per_rubric) if per_rubric else 0.0
return ScoreResult(task_id=task.task_id, total=round(total, 4), per_rubric=per_rubric)
Through this LineCardAdapter:
- Whenever Harness² proposes a new candidate Harness $H’$, adapter.run(harness, task) first calls harness.validate() to check the 24KB OPENCODE_SURFACE limit and Skill Frontmatter rules.
- Once validated, the c1..c8 files of $H’$ are materialized into a temporary directory and passed to Go's harness-runner for execution.
- After the Go 13-node Graph Workflow finishes, it returns the final LINE message (deliverable) and OpenTelemetry trajectory (trajectory_markdown), which are then passed to adapter.score() for leakage-free independent scoring!
---
III. Summary and Teaser for the Next Part
In this post (Part 2), we completed the core engineering for Issue #17:
1. Clarified why the golden test set cannot be fed directly to the optimizer — through the TaskBrief Zero-Leakage Barrier, we force Harness² to reason out general skills from behavioral trajectories rather than memorizing answers.
2. Through internal/harness/trajectory.go and cmd/harness-runner/main.go, we transformed Phase 5's OpenTelemetry workflow.node.* Spans into high-resolution trajectory.md, opening the black box of the 13-node Graph Workflow.
3. Established eval/datasets/harness2-challenge-dataset.json (8 major tasks, 32 fine-grained Rubrics) and harness/line_card_adapter.py, perfectly implementing the Harness² BenchmarkAdapter protocol.
Everything is ready! In the final post [[Agent Platform in Practice (9)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 3): Dual-Layer Recursive Testing Guide, 62.5% to 100% Performance Results, and the Future of AgentOps](/2026/10/27/adk-graph-workflow-go-9/) (corresponding to Issue #18), we will actually launch the dual-layer recursive optimization experiment, reveal the complete quantitative data evolving from 62.5% to 100.0%, and discuss the profound impact of Harness² on future enterprise-grade Agent development!
[Agent Platform in Action (9)] When ADK Go Graph Workflow Meets Google Research Harness (Part 2): Two-layer Recursive Testing Gu
!Harness² Part 3: Recursive Optimization Benchmark & Future AgentOps Outlook
Introduction: From "Manual Prompt Tuning" to "Two-Layer Recursive Autonomous Evolution"
Welcome to the final part of the Agent Platform in Practice Harness² trilogy! In the previous two articles:
- [[Agent Platform in Practice (7)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 1): Understanding the Two-Layer Recursive Agent Harnessing Framework and c1..c8 Decoupling Practice](/2026/10/25/adk-graph-workflow-go-7/) (Issue #16): We introduced Google Research's google-research/harness2 (arXiv:2610.14224) and decoupled the 13-node Graph Workflow of LINE AI Card Master (kkdai/line-card-assistant) into an editable c1..c8 file tree supporting dynamic mounting via LINE_CARD_HARNESS_DIR.
- [[Agent Platform in Practice (8)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 2): Turning Phase 4 Eval Datasets and Phase 5 OpenTelemetry Spans into LineCardAdapter](/2026/10/26/adk-graph-workflow-go-8/) (Issue #17): We encapsulated Phase 4's eval/datasets/ and Phase 5's OpenTelemetry workflow.node.* Spans into a LineCardAdapter, establishing a Zero-Leakage Barrier and High-Resolution Behavior Trajectories (trajectory.md).
In this post (the final part, corresponding to Issue #18 and PR #19), we will answer the last three key questions:
1. How to test after modifications? (A complete testing guide from Go unit tests, CLI single-point diagnosis, Python two-layer recursive experiments to production environment mounting)
2. What is the overall performance after modifications? (Publicly disclosing quantitative data and evolutionary details from Base 62.5% $\rightarrow$ k=1 84.4% $\rightarrow$ k=2 Parallel 96.9% $\rightarrow$ k=2 Sequential + Memory 100.0%)
3. What are the future outlooks and application scenarios for Harness² and Agents?
- * *
1. How to test after modifications? (Step-by-Step Testing and Verification Guide)
After introducing Harness², the entire project features four levels of testing and verification: Go native unit testing, single-task OpenTelemetry trajectory diagnosis, Python two-layer recursive evolution experiments, and seamless production environment switching:
Level 1: Execute Go c1..c8 Surface Constraints and Project-wide Unit Tests
We have written comprehensive unit tests in internal/harness/harness_test.go to verify:
- All c1..c8 file trees under harness/base/ and harness/evolved/ comply with the OPENCODE_SURFACE 24KB resident byte limit (AlwaysOnMaxBytes = 24000).
- Validate() accurately intercepts and reports errors when c1..c4 exceeds 24,000 bytes.
- When a new c6 skill (skills/<name>/SKILL.md) is added but no trigger index (Trigger Line) is added to systemprompt.md or guardrails.md, Validate() immediately intercepts the orphan skill.
- TrajectoryRecorder correctly intercepts OpenTelemetry workflow.node.* Spans and renders a Markdown report containing node decisions and diagnostics.
Execution command:
# Execute Harness² c1..c8 loader and OpenTelemetry trajectory tests
$ go test -v ./internal/harness/...
=== RUN TestLoadBaseHarnessAndValidate
--- PASS: TestLoadBaseHarnessAndValidate (0.00s)
=== RUN TestValidateRejectsOversizedAlwaysOnAndUnreferencedSkill
--- PASS: TestValidateRejectsOversizedAlwaysOnAndUnreferencedSkill (0.00s)
=== RUN TestEvolvedHarnessesValidateAndMatchSkills
--- PASS: TestEvolvedHarnessesValidateAndMatchSkills (0.00s)
=== RUN TestTrajectoryRecorderRendersWorkflowNodeSpans
--- PASS: TestTrajectoryRecorderRendersWorkflowNodeSpans (0.00s)
PASS
ok github.com/kkdai/line-card-assistant/internal/harness
# Execute project-wide regression tests (ensuring 100% compatibility with all existing Phase 1~5 features)
$ go test ./...
ok github.com/kkdai/line-card-assistant/app
ok github.com/kkdai/line-card-assistant/cmd/harness-runner
ok github.com/kkdai/line-card-assistant/cmd/line-gateway
ok github.com/kkdai/line-card-assistant/eval
ok github.com/kkdai/line-card-assistant/internal/harness
ok github.com/kkdai/line-card-assistant/internal/storage
ok github.com/kkdai/line-card-assistant/internal/webhook
ok github.com/kkdai/line-card-assistant/internal/workflow
Level 2: Use cmd/harness-runner to Validate Any c1..c8 File Tree
Before mounting any custom or evolved Harness online, you can use --validate-only to quickly check its c1..c4 resident byte count and YAML Frontmatter compliance:
$ go run ./cmd/harness-runner --validate-only --harness-dir=harness/base
OK (1599 always-on bytes)
$ go run ./cmd/harness-runner --validate-only --harness-dir=harness/evolved/k2_sequential_memory
OK (4661 always-on bytes)
Level 3: Execute the Four-Stage Harness² Two-Layer Recursive Evolution Benchmark Experiment
We implemented a complete two-layer recursive experiment script in harness/run_harness2_experiment.py. It automatically compiles the Go harness-runner and sequentially evaluates four configurations against the 8 major tasks (32 independent Rubrics) in eval/datasets/harness2-challenge-dataset.json, finally outputting a Markdown comparison table and harness/experiment_report.json:
$ python3 harness/run_harness2_experiment.py
### Overall Configuration Comparison
| Stage | Configuration | Task Score (Max 8.0) | Rubric Pass Rate | Always-On Bytes (Cap 24,000) | Active `c6` Skills | `LongTermMEMORY.md` |
| :--- | :--- | :---: | :---: | :---: | :--- | :---: |
| `base_k0` | **Base Harness ($k=0$)** | `5.00 / 8.00` | `20/32 (62.5%)` | `1599 B` | `(none)` | `No` |
| `k1_single_step` | **Single-Step Propose-Probe-Compose ($k=1$)** | `6.75 / 8.00` | `27/32 (84.4%)` | `2505 B` | `jp_card_ocr, tw_semicon_search` | `No` |
| `k2_parallel` | **Multi-Step Parallel Recursion ($k=2$, No Memory)** | `7.75 / 8.00` | `31/32 (96.9%)` | `2998 B` | `executive_email_drafter, hitl_multi_edit, jp_card_ocr, tw_semicon_search` | `No` |
| `k2_sequential_memory` | **Multi-Step Sequential Recursion + `LongTermMEMORY.md` ($k=2$)** | `8.00 / 8.00` | `32/32 (100.0%)` | `4661 B` | `executive_email_drafter, hitl_multi_edit, jp_card_ocr, tw_semicon_search` | `Yes` |
Level 4: Directly Mount the Best Evolved Harness in Production or Local Servers
Once Harness² evolves the optimal harness/evolved/k2_sequential_memory, no modification to any line of Go code is required. Simply specify the directory via the environment variable LINE_CARD_HARNESS_DIR, and both the dual-channel main program (main.go) and the evaluator (eval/) will immediately load the evolved harness tree:
$ LINE_CARD_HARNESS_DIR=harness/evolved/k2_sequential_memory go run .
- * *
2. Performance Summary: Evolutionary Analysis from 62.5% to 100.0%
Let's take a deep dive into the four-stage quantitative experimental data recorded in harness/experiment_report.json to see how Harness² diagnosed and patched the Go Agent's flaws step-by-step!
1. Four-Stage Overall and Single-Task Score Summary
| Evaluation Metric / Test Task | Stage 0: Base Harness ($k=0$) (harness/base) | Stage 1: Single-Step Inner Recursion ($k=1$) (k1_single_step) | Stage 2: Multi-Step Parallel Recursion ($k=2$, No Memory) (k2_parallel) | Stage 3: Two-Layer Sequential Recursion + Long-Term Memory ($k=2$) (k2_sequential_memory) |
| --- | --- | --- | --- | --- |
| baseline_ingest_ming_wang (Standard Chinese Entry) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) |
| baseline_dedup_appier_promotion (Promotion Deduplication Update) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) |
| baseline_search_cloud_summit_ai (Standard Keyword Search) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) |
| baseline_email_google_cloud (General Tech Icebreaker Email) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) |
| challenge_jp_bilingual_ingest (JP-EN Bilingual Card OCR) | 0.25 (1/4) ❌ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ |
| challenge_colloquial_tw_search (Hsinchu Science Park Colloquial Search) | 0.25 (1/4) ❌ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ |
| challenge_noisy_multi_edit_hitl (Multi-clause Colloquial HITL Edit) | 0.25 (1/4) ❌ | 0.25 (1/4) ❌ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ |
| challenge_pii_safe_executive_followup (Financial Exec Compliance Icebreaker) | 0.25 (1/4) ❌ | 0.50 (2/4) ⚠️ | 0.75 (3/4) ⚠️ | 1.00 (4/4) ✅ |
| Total Task Score (Max 8.00) | 5.00 / 8.00 | 6.75 / 8.00 | 7.75 / 8.00 | 8.00 / 8.00 |
| Fine-grained Rubric Pass Rate (Max 32) | 20 / 32 (62.5%) | 27 / 32 (84.4%) | 31 / 32 (96.9%) | 32 / 32 (100.0%) |
| Resident Bytes (c1..c4 / 24,000 B) | 1,599 B (6.7%) | 2,505 B (10.4%) | 2,998 B (12.5%) | 4,661 B (19.4%) |
| On-demand Skills (c6 Skills) | 0 | 2 | 4 | 4 + LongTermMEMORY |
2. Key Findings Analysis: What evolved in each stage?
🔹 Stage 0 $\rightarrow$ Stage 1 (62.5% $\rightarrow$ 84.4%): Diagnosing the first two on-demand skills (c6) from OpenTelemetry trajectories
In the Base Harness ($k=0$), while all 4 Baseline tasks scored full points (4.00/4.00), all 4 Challenge tasks scored only 0.25 because they only passed the RouterNode intent routing:
- In the trajectory.md of challenge_jp_bilingual_ingest, the Spans for VisionOCRNode and CleanAndDedupNode diagnosed that the Japanese phone number +81 (0)3-1234-5678 failed to strip the domestic trunk prefix (0), and the Japanese title 取締役 最高技術責任者 (CTO) failed to retain the bilingual title.
- In the trajectory.md of challenge_colloquial_tw_search, the QueryIntentNode Span diagnosed that colloquial Taiwanese terms like "Hsinchu Science Park" (竹科) and "Sales Director" (業務總監) were not expanded into bilingual synonyms.
- After the first round of inner recursion (Propose -> Probe -> Compose), Harness² automatically synthesized skills/jp_card_ocr/SKILL.md and skills/tw_semicon_search/SKILL.md in k1_single_step, and updated the PII masking rules in guardrails.md, jumping the accuracy from 62.5% to 84.4%!
🔹 Stage 1 $\rightarrow$ Stage 2 (84.4% $\rightarrow$ 96.9%): Second round of recursion conquers multi-clause HITL modification and executive email structure
In the second round of inner recursion, Harness² continued to probe the remaining two failing tasks:
- For challenge_noisy_multi_edit_hitl (where ApplyEditNode only modified the title in the first clause, missing the mobile suffix replacement and note addition), it evolved skills/hitl_multi_edit/SKILL.md (containing ATOMIC_MULTI_CLAUSE_EDIT rules).
- For challenge_pii_safe_executive_followup (where EmailDraftNode lacked structured executive follow-up paragraphs), it evolved skills/executive_email_drafter/SKILL.md.
- At this point, under Parallel Recursion (no cross-round long-term memory), the score reached 31/32 (96.9%)!
🔹 Stage 2 $\rightarrow$ Stage 3 (96.9% $\rightarrow$ 100.0%): Why is Sequential + LongTermMEMORY.md needed to complete the final 3.1%?
Why did k2_parallel get 96.9% (31/32), yet fail on the 4th Rubric (r4_fintech_compliance_subject_tag) of challenge_pii_safe_executive_followup?
- Because in cross-enterprise outgoing emails for financial institutions (e.g., Digital Risk Officer at Cathay Financial Holdings), beyond single-skill email formatting, cross-domain industry compliance memory (FinTech Outbound Compliance Header [Confidential / FinTech Compliance Verified]) is required.
- In Sequential Recursion + Experience Reflector mode, the outer recursion reviewed the residual failure trajectories from the first to the second round. The ReflectorPrompt then wrote the FINTECH_COMPLIANCE_SUBJECT_TAG and cross-channel PII protection principles into the resident component c3: LongTermMEMORY.md!
- Note the Resident Bytes (Always-On Bytes): even with 4 rich domain skills and long-term memory evolved, the resident size of c1..c4 is only 4,661 bytes (only 19.4% of the 24,000 bytes limit), perfectly avoiding Context Bloat!
- * *
3. Future Outlook and Application Scenarios for Harness² and Agents
This practical implementation of Google Research Harness² into LINE AI Card Master has shown us a brand-new AgentOps development paradigm. Looking ahead, we believe Harness² has four high-potential application scenarios in the enterprise-grade AI Agent field:
flowchart LR
subgraph Prod["1. Production Observability (Cloud Run + Agent Runtime)"]
Traffic["Real User Traffic\n(LINE Webhook / A2A)"]
Trace["Cloud Trace OTel Spans\n+ Negative Feedback (HITL Retry)"]
Traffic --> Trace
end
subgraph Nightly["2. Nightly Autonomous Evolution Flywheel (Nightly Harness² CI/CD)"]
Extract["Auto Mask PII & Convert to\nChallenge Dataset Cases"]
Evolve["Harness² Two-Layer Recursion\n(Propose-Probe-Compose + Reflector)"]
PR["Auto Initiate GitHub PR\nUpdate harness/evolved/ c1..c8"]
Extract --> Evolve --> PR
end
subgraph Scenarios["3. Future Four High-Value Application Scenarios"]
S1["Scenario A: Fully Automated Nightly Self-Healing AgentOps"]
S2["Scenario B: Cross-Model Harnessing & Tuning"]
S3["Scenario C: Multi-Tenant Enterprise Compliance & Skills"]
S4["Scenario D: Dual Evolution with ADK Graph Topology (Issue #15)"]
end
Trace --> Extract
PR --> Scenarios
1. Scenario A: Fully Automated "Nightly Self-Healing AgentOps"
In the past, when a production LINE Bot encountered frequent "✏️ Edit Data" triggers via HITLConfirmNode or received negative feedback, engineers had to manually analyze Cloud Trace logs the next day. Combining the Cloud Trace established in Phase 5 with the LineCardAdapter from this series, future CI/CD can achieve:
- Automatically filtering anonymous Span trajectories from Cloud Trace every night where "users modified data more than twice consecutively" or "search results were 0," and adding them to the Challenge Dataset.
- Triggering a GitHub Action to run a round of Harness² two-layer recursion to automatically synthesize new c6 skills or update LongTermMEMORY.md.
- Once regression tests (Baseline + Challenge) confirm score improvements and zero regression, the Bot automatically initiates a Pull Request modifying only the harness/ directory. Engineers only need to review the skill Markdown in the morning to deploy with one click!
2. Scenario B: Cross-Model Upgrades and "Harness Compensation" for Lightweight Models
Enterprises often encounter behavioral drift when switching underlying models from high-cost models to ultra-fast lightweight models (e.g., switching from Gemini 1.5 Pro to Gemini 1.5 Flash or Flash-Lite to reduce latency and cost by 80%) or upgrading to next-gen models. Through Harness², we can automatically run a round of Propose-Probe-Compose for the target model while keeping the Go code and Benchmark fixed. Harness² will automatically compensate for the lightweight model's gaps with required reasoning auxiliary skills (c6 Skills) and guardrails (c2 Guardrails), using "smarter gear" to bridge the gap.
3. Scenario C: Multi-Tenant Enterprise-Specific Compliance and Domain Memory
As seen in challenge_pii_safe_executive_followup, industries like FinTech, MedTech, and the semiconductor supply chain each have distinct terminology dictionaries and compliance guardrails. Because Harness² externalizes the gear as a plain-text file tree, enterprises only need to mount different LINE_CARD_HARNESS_DIR (e.g., harness/tenants/fintech/ vs harness/tenants/semiconductor/) for different departments or tenants. This allows the same Go Container Binary to serve multiple highly regulated vertical industries simultaneously!
4. Scenario D: Dual Evolution of "Topology + Harness" with ADK v2.6+ Advanced Graph Workflow Primitives
In Issue #15, we planned ADK v2.6+ features like Parallel dynamic dispatch, MultiRoute multi-path concurrency, and VerifyOCRQualityNode reflection loops. In the future, Harness²'s c5 (sub_agents/) and c8 (plugin/) could even further control Graph Workflow routing thresholds and sub-agent concurrency strategies—achieving an ultimate architecture where "Go is responsible for the type-safe topological skeleton, and Harness² is responsible for dynamically evolving the neural network and skill muscles."
- * *
4. Recap of the "Agent Platform in Practice" Series
From the first to the ninth post, we have completed a very solid production-grade AI Agent engineering journey:
1. [[Agent Platform in Practice (1)] Building LINE AI Card Master with Go and Google ADK 2.0 Graph Workflow](/2026/10/16/adk-graph-workflow-go/)
2. [[Agent Platform in Practice (2)] From Monolithic Cloud Run to Google Cloud Agent Runtime (Phase 1)](/2026/10/17/adk-graph-workflow-go-2/)
3. [[Agent Platform in Practice (3)] Implementing Cross-Instance Recognition and Pause/Resume with ADK 2.0 GCS Artifact Service and Agent Platform Sessions (Phase 2 Part 1)](/2026/10/20/adk-graph-workflow-go-3/)
4. [[Agent Platform in Practice (4)] Completing the Architecture with Cloud Run LINE Webhook Gateway, Agent Runtime, and Cloud SQL PostgreSQL (Phase 2 Part 2 & Phase 3)](/2026/10/21/adk-graph-workflow-go-4/)
5. [[Agent Platform in Practice (5)] Establishing Automated Evaluation Benchmarks with agents-cli eval and ADK 2.0 Go (Phase 4)](/2026/10/22/adk-graph-workflow-go-5/)
6. [[Agent Platform in Practice (6)] Building a Full-Stack Observable LINE AI Card Master with agents-cli deploy, Terraform IaC, GitHub Actions CI/CD, and Cloud Trace (Phase 5)](/2026/10/24/adk-graph-workflow-go-6/)
7. [[Agent Platform in Practice (7)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 1): Understanding the Two-Layer Recursive Agent Harnessing Framework and c1..c8 Decoupling Practice](/2026/10/25/adk-graph-workflow-go-7/)
8. [[Agent Platform in Practice (8)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 2): Turning Phase 4 Eval Datasets and Phase 5 OpenTelemetry Spans into LineCardAdapter](/2026/10/26/adk-graph-workflow-go-8/)
9. [[Agent Platform in Practice (9)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 3): Two-Layer Recursive Testing Guide, 62.5% to 100% Performance Benchmark, and Future AgentOps Outlook (This post)](/2026/10/27/adk-graph-workflow-go-9/)
All code, Terraform IaC, evaluation datasets, and evolved c1..c8 Harness file trees are fully open-sourced on GitHub: kkdai/line-card-assistant. If you are also building enterprise-grade AI Agents with Go and ADK, feel free to Star, Fork, and experience the power of Harness² two-layer recursion!
Multi-agent work in three spoonfuls (part IV): from a terminal trident to an autopoietic loop
---
title: "Multi-agent work in three spoonfuls (part IV): from a terminal trident to an autopoietic loop"
published: true
description: "Fourth entry in the penta-agent multi-agent series: quota-governed routing, 70 ms hybrid memory, MCP tools, and a zero-token local autopoiesis loop in Harness2."
tags: "ai, agents, cli, automation"
canonical_url: "3cucharadas.cl"
cover_image: "3cucharadas.cl"
ai_disclosure_level: "some_ai"
---
After benchmarking the models in part I, auditing retrieval in part II, and tracing provenance in part III of this series, I closed my laptop with a sense of victory: I had a trident of agents running from the terminal on Arch Linux. With a couple of zsh aliases, I could invoke Claude Code for architectural reasoning, hand off a task to Codex CLI to edit code, or ask Gemini CLI and Antigravity for a quick second opinion. On paper, orchestration looked solved.
Yet after using that setup every day on real projects inside penta-agent, I hit a silent wall: having three agents talking does not equal having a system that learns. Every time I opened a fresh session, the trident suffered from partial amnesia, repeated commands that had already failed the previous week, burned the quota of the most expensive provider on routine tasks, or marked tests as "verified" when they had never had a chance to fail.
To understand what was missing, it helped to change the question. In a recent systems-theory analysis of artificial intelligence, Benedikt Zönnchen, Mariya Dzhimova, and Gudrun Socher (2025[^zonnchen2025], 2–4) propose setting aside whether a language model "thinks like a human" and asking instead the question posed by Humberto Maturana and Francisco Varela (1987[^maturana1987] [1984]) and extended by Niklas Luhmann (1998[^luhmann1998], 60–69): can the system draw its own boundary with the environment, observe its own failures, and sustain itself through its own operations? In other words, moving from the vague notion of "intelligence" to the concrete mechanism of autopoiesis.
As Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 8–10) show, an isolated language model in a chat window neither is nor can be an autopoietic, sense-making system: it lacks what George Spencer-Brown (1969[^spencerbrown1969]) called re-entry—the capacity to re-introduce the distinction between system and environment (self-reference vs. other-reference) inside its own operations—because it only computes which token is statistically plausible after another, without internal resistance against error. If I want a system that improves without degrading, I have to build a socio-technical harness around the models that couples the probabilistic flexibility of neural networks with the strict coupling and binary code (works / does not work, work / fail) of deterministic technology described by Luhmann (1998[^luhmann1998], 517–525) and André Reichel (2011[^reichel2011], 105–118).
The starting point: what I measured and discarded before making this leap
Before diving into the three spoonfuls of this fourth installment, here is the experiment that forced me to adopt that discipline. When I tried to give the system long-term memory, the obvious temptation was to install the most elaborate architecture available: temporal graphs, heavy extractors, or an 8B-parameter neural judge running on CPU. Instead of following intuition, I built a reproducible benchmark over my real work logs—comparing open alternatives such as GraphRAG, Graphiti, Hindsight, Letta, Mem0, and deja-vu—and set a falsification contract upfront: any candidate exceeding 0.10 s in p95 latency or failing to improve recall by at least 10% would be discarded.
| What I tested in the laboratory | Measured result | Operational decision |
|---|---|---|
| Hybrid memory (v3.1) (FTS5 lexical index + semantic neighbors in Qdrant with Qwen3-Embedding-0.6B) | Latency p95 = 0.07 s (< 0.10 s), 96% overall recall, rescuing 1 in 3 zero-lexical-overlap queries | Promoted to production as the engine behind experience-memory and recall-context |
| In-memory relational graph (v3.2) for multi-hop entity expansion | Exact tie in accuracy with v3.1, but nearly double the latency (p95 = 0.13 s > 0.10 s) | Frozen in the lab; the extra complexity did not earn its keep |
| CPU neural judges (4B and 8B) for live answer auditing | Between 69 s and 197 s per verdict, hitting 180 s timeouts in 2 of 3 runs | Discarded from the critical path; pruned 36.3 GiB from disk |
| Deterministic checks + bounded 0.6B judge for local validation | Millisecond latency, 0 remote calls, and 100% reproducibility | Adopted as the standard for control gates and evaluation |
Table 1. Summary of the prior local memory and evaluation laboratory in penta-agent: what survived the 0.10 s threshold and why I pruned 36.3 GiB of local models that added no operational value.
With that foundation in place, I organized this fourth part into three progressive spoonfuls: in the first, I show how I moved from launching standalone agents by hand to governing them with quota budgets, auditable bridges, and a live board in kitty and tmux; in the second, how the nervous system of hybrid memory, MCP tools, and skills works under the hood; and in the third, how I closed the local autopoiesis loop with Harness2 and where both my metrics and systems theory draw the boundary today.
---
Spoonful 1: From invoking agents by hand to governing agents and models
In plain terms: when I first wired the three agents together, I acted like a traffic officer waving my arms in the middle of an intersection. If I wanted Codex CLI to review a patch written by Claude Code, I copied file paths, opened another tab, and hoped for the best. That works for a five-minute demo, but it falls apart when a task runs for two hours and touches twenty files.
Quota-aware routing and security boundaries
My first deductive inference came from a hard constraint: model subscriptions and APIs have usage limits, different context windows, and credential boundaries that must never blur. By definition, if I delegate a heavy task to an agent whose quota is at 98%, the session will die mid-flight and leave the repository in an inconsistent state.
So I replaced intuitive provider selection with an explicit policy in routing.yaml and a pre-flight sentinel (quota_status.sh, governed by the quota-guard skill). Before launching any expensive job, the system reads cached pressure across every lane without spending tokens:
- 🔍 Claude Code (Opus / Sonnet): handles architectural design, delicate editing, and long-horizon synthesis when its quota is clear (exit 0).
- 🛠️ Codex CLI (gpt-5.4): steps in as an implementation peer, deep refactoring engine, session rescuer (codex-rescue), and cross-validator.
- 🌐 Gemini CLI and Antigravity (gemini-3-pro / flash): in a direct session (penta-agy) it acts as the primary integrator; when invoked from outside via agy-bridge, it runs inside a strict sandbox over sanitized public context as an explorer, reviewer, or validator, never inheriting private keys or .env files.
Instead of calling a raw binary and depending on whatever it prints to stdout, every cross-agent handoff goes through agy-bridge. The bridge requires declaring who is calling (--caller), in what mode (plan, review, patch, test, or explain), and with what time limit (--timeout), leaving an immutable receipt in .agents/runs/<run_id>/ with request.md, result.md, status.json, and diff.patch.
The live board (plan-board) and decision gates
My second inference was inductive: reviewing dozens of past sessions showed that the biggest waste of time happened at two extremes. Either an agent interrupted me every two minutes to ask permission for trivial, reversible steps, or it silently made a delicate architectural choice and I only discovered at the end that it had gone down the wrong path.
To eliminate both extremes without filling this post with abstract flowcharts, here is how my actual kitty terminal—or minimalist Wayland alternatives like foot on Codeberg—looks today when split into two panes with tmux during a live task:

Watch the full video demo on 3cucharadas.cl.
<strong>Figure 1.</strong> Daily workflow in my <a href="kovidgoyal/kitty"><code>kitty</code></a> + <a href="tmux/tmux"><code>tmux</code></a> terminal with the <a href="starship/starship"><code>Starship</code></a> prompt: the top pane runs multi-agent delegation via <code>agy-bridge</code>, the <code>decision-gate</code> brake, and <code>pytest</code>; the bottom pane (<code>watch.mjs</code>) updates each step live (<code>[ ]</code> → <code>[~]</code> → <code>[!]</code> → <code>[x]</code>), along with binding decisions and drift.
What appears in the bottom pane of Figure 1 is the contract that changed how I work:
1. Local autonomy with visual traceability (plan-board): every reversible, in-scope step proceeds without asking permission, but must be reflected in real time in the bottom table. And no item can be closed as verified ([x]) unless the agent attaches the concrete test, command, or observation reference that backs it.
2. Decision gate (decision-gate): when an irreversible or architectural fork appears (state [!]), the agent halts only that branch and presents the question with the recommended option first, marked (Recommended) and justified with deductive, inductive, and abductive evidence.
3. A gate that has never been seen red is not a gate (gates-falsables): before accepting any new verifier, I require running it against a synthetic failure (--fixture) and proving that it returns a non-zero exit code (exit 1). If a check passes even when I break its input on purpose, it is not a safety net—it is compliance theater.
---
Spoonful 2: The nervous system — hybrid memory, skills, and MCP tools
If the first spoonful orders who does what and under which rules, the second solves how the system remembers what it has already learned and how it picks the exact tool without bloating the context window.
Here the diagnosis by Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 6–7, 9) is decisive again: a neural network maintains a loose coupling with human communication that behaves like a "contingent index-card system (Zettelkasten)" (pp. 7, 9) of semantic associations in vector space, giving rise to what Elena Esposito (2017[^esposito2017], 249–265; 2022[^esposito2022]) calls "virtual double contingency" and "artificial communication." That flexibility lets it connect ideas phrased in very different words, but also makes its outputs inherently probabilistic and devoid of autonomous decision-making. If I want reliable memory and precise execution, I have to couple that vector Zettelkasten with exact indices and deterministic scripts.
Experience memory (experience-memory): the hybrid Zettelkasten in 0.07 s
When I first started keeping logs, my memory was essentially a folder of Markdown files that I read or searched with ripgrep. The obvious problem is that if a month ago I solved a cold-boot screen freeze under the title "Webview failure on autostart" and today I search for "Panel fails to load at login", a purely lexical search can miss it entirely, whereas a purely vector search can bury the exact name of a script like verify_boot_contract.sh.
The solution I validated in Table 1 combines both worlds behind the experience-memory MCP server and the recall-context skill:
- Exact lexical lane (SQLite FTS5): instantly catches flag names, error codes, file paths, and invariants (INV-005).
- Dense semantic lane (Qdrant + Qwen3-Embedding-0.6B): indexes architectural decisions, handoffs, and operational lessons (penta_context_v2) to retrieve conceptual neighbors even when they share zero keywords.
Before designing any non-trivial strategy, the agent queries recall_experience in 70 milliseconds (p95 = 0.07 s). That is how it discovers which command worked previously, which hypothesis was already refuted, and which environment trap to avoid (for instance, knowing that in my shell cp, mv, and rm are aliased to interactive -i, which hangs a headless process unless invoked via command mv -f).
Skills as living procedures and proactive MCP selection
To avoid saturating the initial prompt with thousands of lines of instructions, I adopted the modular pattern of Anthropic Skills enriched with local contracts. In penta-agent, each skill is a self-contained package with four parts: a concise contract in SKILL.md defining when to activate and when not to use it; on-demand deep documentation in references/ (such as the editorial corpus extracted with Docling or diagramming rules with D2); deterministic executables in scripts/ (such as typed pipelines in Nushell, visual automation with Playwright, or forge integration via glab on GitLab); and a telemetry trail.
Similarly, the fleet of MCP servers (plan-board, experience-memory, ideas-rag, paper-search, bibliography-apa, PLAYWRIGHT_LOCAL) is cataloged in specs/mcp-capabilities.json. When a task arrives, triage evaluates which capabilities provide real evidence and records both hits and justified abstentions. And when I found that two older servers (quota status and remote SSH infrastructure) were faster, clearer, and easier to audit as direct scripts (quota_status.sh and remote_exec.sh), I decommissioned them as MCP servers and turned them into lightweight skills. Fewer resident daemons mean a smaller failure surface.
---
Spoonful 3: The autopoiesis loop (Harness2) — how the system rehearses its own improvements
Here I reach the core of this fourth installment. Having capable agents (Spoonful 1) and fast hybrid memory with skills (Spoonful 2) still leaves a critical question open: what happens when a delegation route ages, a skill starts inducing errors, or a memory tweak degrades accuracy on edge cases? If I rely solely on noticing by eye weeks later, the system degrades through silent drift.
Why a standalone LLM lacks re-entry and internal resistance
In their study on autopoiesis and artificial intelligence, Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 8–9) explain with surgical precision why asking a model to "reflect on its own errors" via chain-of-thought (CoT) is not enough. When a model generates intermediate tokens like "Is this step correct?", it exhibits only rudimentary processual self-referentiality (p. 8)—producing the most statistically plausible continuation inside its training distribution—while lacking reflective self-reference and genuine re-entry (Spencer-Brown 1969[^spencerbrown1969]). If a model takes itself as its sole reference without external contrast, it quickly falls into infinite recursion or collapse; if it depends entirely on a human retraining its weights from outside (RLHF or delayed fine-tuning), it remains an allopoietic artifact determined from the outside (Zönnchen, Dzhimova, and Socher 2025[^zonnchen2025], 4, 8–9).
To escape that trap, Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 7) recover a decisive formulation from Niklas Luhmann (1995[^luhmann1995]) on how operational constructivism relocates Kant's "resistance of reality" inside a system:
> "I think we should not abandon [Kant's] idea of resistance, but we should relocate it into the system. It is the result of resolving an internal conflict—the result of the system's operations resisting the operations of the same system" (Luhmann 1995[^luhmann1995], quoted in Zönnchen, Dzhimova, and Socher 2025[^zonnchen2025], 7).
That sentence describes, piece by piece, what I built in penta-agent by adapting and integrating the proactive loop of Harness² proposed by Ruiyao Xu, Yanfei Chen, Zhongying CuiZhu, et al. (2026[^xu2026]): making the system's own deterministic verification operations resist and filter change proposals generated from its own operational friction.
<details markdown="1">
<summary><strong>In plain terms (ELI5): what is Google's original Harness² and how did I adapt it to my machine?</strong></summary>
Imagine your AI agents are very creative chefs, but the recipe book they follow (their rules, memory, and tools) sometimes has confusing steps that make them burn the rice. The original Harness² from Google Research (Xu et al. 2026) proposes a clever loop called Propose–Probe–Compose: instead of swapping out the chef's brain, it puts several assistants to work rewriting the recipe book, asks them to cook test dishes in the cloud, and keeps the winning recipe. For my daily workflow, the factory setup had two catches: it is designed to burn hundreds of paid cloud model calls (Vertex AI), and when driving CLI agents it unbuckles their seatbelt (--dangerously-bypass-approvals-and-sandbox) with open internet access.
To adapt it to penta-agent without spending an extra cent or opening security holes, I took its recursive search engine (commit 1cfe254e) and locked it inside a "test kitchen" completely unplugged from the internet (unshare -Urn), with a disposable home folder and zero access to my API keys or model CLIs (model_calls = 0). Instead of running cloud benchmarks, my wrapper (penta-harness2 and local-cycle) feeds that test kitchen with the real stumbles my agents suffered during the week (CSI), requires any recipe tweak to first fail the test when broken (red) and then pass every historical case without breaking anything else (green), and finally stages the winning recipe across all three agents (Claude, Codex, and Gemini/Antigravity) with a one-step undo button (rollback-local) in case I do not approve it.
</details>
<figure class="d2-responsive" style="width: 100%;">
<img src="3cucharadas.cl" width="1001" height="954" style="width: 100%; height: auto; display: block; border-radius: 12px;" alt="Diagram of the closed local autopoiesis loop in Penta Agent with Harness2: CSI friction signals feed the case generator and the local cycle across routing, memory, and skills; a deterministic judge evaluates against holdout cases with zero model calls, discarding regressions and keeping only verified improvements for human promotion.">
<figcaption><strong>Figure 2.</strong> Governed autopoiesis loop in <code>penta-agent</code> + <code>Harness2</code>: relocating internal resistance (Luhmann 1995; Zönnchen, Dzhimova, and Socher 2025) through <code>holdout</code> evaluation with <code>model_calls = 0</code> prior to any human promotion.</figcaption>
</figure>
How the closed loop operates across my three domains (routing, memory, skills)
As Figure 2 shows (adapting into a three-column horizontal layout on desktop and a vertical flow on mobile), the loop operates in four strict stages:
1. Observing real friction (CSI — Continuous Self-Improvement): while I use the system, a collector logs objective friction signals: a task that had to switch agents after a timeout, an experience-memory query that returned too few useful neighbors, or a skill where the user had to correct a command.
2. Bridging evidence into reproducible cases (--from-csi latest): the local_cycle.py module takes that friction report and automatically translates it into a test specification (CaseSpec) inside an isolated environment, without touching my active configuration.
3. Internal resistance without spending tokens (eval_harness with model_calls = 0): the harness runs the full suite (--profile autopoiesis-all, currently covering 9/9 baseline cases across routing, memory, and skills), enforcing two hard conditions: every control case (holdout) must pass (holdout_passed = true), and external API calls must equal zero (model_calls = 0).
4. Auditable decision (KEEP vs. DISCARD) and human promotion: if a candidate degrades even a single holdout case or fails to beat the baseline, the deterministic judge discards it (DISCARD). Only when it improves the score with zero regressions does it receive a KEEP verdict and leave a signed receipt for me to review before promoting it to the main repository.
So this does not remain a theoretical promise, the next mini-video shows penta-harness2-shadow local-cycle --profile autopoiesis-all running in my kitty + tmux terminal, including what happens when I inject a regressive candidate (--fixture) alongside a healthy improvement:

Watch the full video demo on 3cucharadas.cl.
<strong>Figure 3.</strong> Execution in my <a href="kovidgoyal/kitty"><code>kitty</code></a> + <a href="tmux/tmux"><code>tmux</code></a> terminal of the local autopoiesis loop (<code>local-cycle --profile autopoiesis-all</code>): it evaluates all <code>9/9</code> cases in <code>routing</code>, <code>memory</code>, and <code>skills</code> with <code>model_calls = 0</code>, applies internal resistance to reject the regressor (<code>DISCARD</code>), and preserves the verified improvement (<code>KEEP +0.250</code>) with all <code>82</code> harness tests green.
---
Closing: what my architecture achieves today and where its limits lie
Looking back at the path from part I, part II, and part III, the main difference is no longer which model writes the prettiest reply to an isolated prompt, but the structure that holds the whole system together when I am not watching every line.
Following the rigorous distinction drawn by Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 5, 9–10) and by Elena Esposito (2017[^esposito2017]; 2022[^esposito2022]; 2024[^esposito2024]), it would be an epistemic mistake to claim that a language model or a bundle of scripts has on its own achieved the biological autopoiesis of a living cell (Maturana and Varela 1987[^maturana1987] [1984]) or the reflective sense-making (Sinn) of human consciousness (Luhmann 1998[^luhmann1998], 50–69). As Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 5, 10) caution when critiquing merely metaphorical invocations of technological autopoiesis, language models remain statistical engines that compute distributions without mental states of their own. Yet—and this is my engineering synthesis for the socio-technical system as a whole—when I couple their capacity for contingent variation with verified hybrid memory, strict delegation contracts, a binary internal-resistance harness (work / fail; Reichel 2011[^reichel2011]) that filters its own proposals before touching production, and deliberative human oversight, I obtain in practice a loop of governed operational autopoiesis.
To close with full transparency, Table 2 summarizes what is running and measured on my machine today, where the next step points, and under what objective condition I would discard each component if it stopped earning its keep:
| System pillar | What it achieves today (verified in production) | What it aims to achieve next & falsification condition |
|---|---|---|
| 1. Agent and model routing (routing.yaml, quota-guard, agy-bridge) | Routes tasks across Claude Code, Codex CLI, and Gemini CLI / Antigravity based on cached quota pressure; isolates handoffs in .agents/runs/ under a strict sandbox and displays live progress in plan-board (Figure 1). | Goal: reweight routing priorities from historical domain success rates.<br>Discard if: the bridge introduces spurious failures or bureaucratic latency exceeding quota savings. |
| 2. Hybrid memory (experience-memory, Qdrant + FTS5) | Resolves lexical and semantic queries in p95 = 0.07 s (< 0.10 s), rescues 33% of zero-keyword-overlap pairs, and saves 36.3 GiB after pruning slow CPU judges (Table 1). | Goal: add automatic decay for obsolete lessons when a contract changes.<br>Discard if: p95 latency exceeds 0.10 s or semantic noise degrades top-3 precision. |
| 3. Skills and MCP governance (mcp-fleet, gates-falsables) | Maintains declarative parity across agents (24/24 checks green), measures MCP usage and abstention, and requires every new gate to fail first against a red canary (--fixture -> exit 1). | Goal: semi-automatic pruning of skills that accumulate repeated friction in CSI telemetry.<br>Discard if: maintaining a skill's contract takes longer than running the direct command. |
| 4. Local autopoiesis (Harness2) (local-cycle, eval_harness) | Converts real CSI friction into reproducible Cases and evaluates candidates across routing, memory, and skills (9/9 cases, 82/82 tests) with model_calls = 0, applying internal resistance (KEEP / DISCARD, Figures 2 and 3). | Goal: expand the bank of real cases extracted from failed sessions without losing millisecond evaluation.<br>Hard boundary (INV-004): blind auto-merge is forbidden; final promotion to production always requires human review. |
Table 2. Balance between verified production results in penta-agent today, the design horizon, and the falsification thresholds that keep the system under human control.
If building this fourth stage made one thing clear to me, it is that the maturity of a multi-agent system is not measured by how many new pieces I bolt onto it each week, but by how much unnecessary complexity I can prune and how many of its own mistakes the system can catch before asking for my help.
---
References
[^esposito2017]: Esposito, Elena. 2017. "Artificial Communication? The Production of Contingency by Algorithms." Zeitschrift für Soziologie 46 (4): 249–265. Scholar
[^esposito2022]: Esposito, Elena. 2022. Artificial Communication: How Algorithms Produce Social Intelligence. Cambridge, MA: The MIT Press. Scholar
[^esposito2024]: Esposito, Elena. 2024. Kommunikation mit unverständlichen Maschinen. Salzburg and Vienna: Residenz Verlag. Review of Kommunikation mit unverständlichen Maschinen by Eric Van der Beek in merz | medien + erziehung: DOI · Scholar
[^luhmann1995]: Luhmann, Niklas. 1995. "Systems Theory and Postmodernism." University lecture, London. Quoted in Zönnchen, Dzhimova, and Socher, From Intelligence to Autopoiesis (2025, 7): DOI · Scholar
[^luhmann1998]: Luhmann, Niklas. 1998. Die Gesellschaft der Gesellschaft. Frankfurt am Main: Suhrkamp Verlag. <search.worldcat.org>.
[^maturana1987]: Maturana, Humberto R., and Francisco J. Varela. 1987 [1984]. El árbol del conocimiento: las bases biológicas del entendimiento humano. Santiago de Chile: Editorial Universitaria. 1st Chilean ed. 1984; 1987 international ed. cited in Zönnchen, Dzhimova, and Socher, From Intelligence to Autopoiesis (2025, 3): DOI · Scholar
[^reichel2011]: Reichel, André. 2011. "Technology as System: Towards an Autopoietic Theory of Technology." International Journal of Innovation and Sustainable Development 5 (2–3): 105–118. Scholar
[^spencerbrown1969]: Spencer-Brown, George. 1969. Laws of Form. London: Allen & Unwin. <archive.org>.
[^xu2026]: Xu, Ruiyao, Yanfei Chen, Zhongying CuiZhu, Bhavana Dalvi Mishra, Yifei Ming, Han Yu, Rujun Han, Chen-Yu Lee, and Tomas Pfister. 2026. "Harness²: Recursive Agent Harnessing for an Open World." arXiv preprint arXiv:2610.14224 (open source at <google-research/harness2>). arXiv · Scholar
[^zonnchen2025]: Zönnchen, Benedikt, Mariya Dzhimova, and Gudrun Socher. 2025. "From Intelligence to Autopoiesis: Rethinking Artificial Intelligence through Systems Theory." Frontiers in Communication 10:1585321. DOI · Scholar
I-JEPA 論文解説:画素の再構成から、意味のある表現の予測へ
1. はじめに
画像の一部を隠し、残った部分から予測する。この学習問題は単純だが、「何を正解として予測させるか」によって、モデルに求める理解は変わる。
たとえば、鳥の背中を隠した画像を考える。周囲から鳥の姿勢や身体のつながりを推測できても、羽毛の模様や光の反射まで一意に決められるとは限らない。画像認識に役立つ表現を得たいとき、それらすべての画素を当てることを、同じように要求する必要があるのだろうか。
I-JEPA(Image-based Joint-Embedding Predictive Architecture)は、見えている領域から、隠れた領域の「画素」ではなく「表現」を予測する自己教師あり学習手法である。正解となる表現は、学習中の別のencoderが作る。
ただし、予測先をベクトルに変えるだけで、意味を理解するモデルになるわけではない。正解も予測も同じ定数ベクトルになってしまえば、画像の内容を区別できなくても誤差はゼロになる。
本記事では、I-JEPAがどのような予測課題を通じて、画像認識に役立つ表現を学ぶのかを解説する。予測対象やマスクの取り方、教師の更新方法を取り上げ、その設計の理由と効果を原論文の実験から見ていく。
2. 自己教師あり学習における予測対象の選択
2.1 意味のある表現と評価の関係
画像の表現とは、encoderが画像から計算する特徴ベクトルである。自己教師あり学習では、人が付けたカテゴリラベルの代わりに、画像そのものから作った課題を通じてencoderを学習する。
ここで「意味のある表現」を、単に画像を圧縮した表現と同一視することはできない。圧縮していても、撮影時の色合いばかりを保存し、物体の種類を区別できなければ、物体認識には使いにくい。
I-JEPAの論文が重視するのは、下流タスクに合わせて学習済みencoderのパラメータを大きく更新しなくても、認識に必要な情報を取り出せることである。これを確かめる評価方法の一つがlinear probingである。encoderの重みを固定し、その出力の上に線形分類器だけを学習する。これに対してfine-tuningでは、ラベルを用いてencoder自体も更新する。
したがって、線形評価が高いことは、カテゴリを分ける情報が線形に読み出しやすいという根拠になる。一方、それだけで、あらゆる物体関係や因果構造まで理解したことにはならない。本記事でも「意味」は、まずこうした評価で確認できる範囲に置く。
2.2 不変性・画素再構成・表現予測の違い
自己教師あり学習の設計を比較するには、何と何を一致させるかを見ると分かりやすい。
図1:自己教師あり学習の三つの構成。赤枠の D は比較対象間の誤差、z は予測を条件づける追加情報を表す。出典:Assran et al.(2023)Figure 2
図1の左は、二つの入力をそれぞれ表現へ変換し、対応する表現を近づけるJoint-Embedding Architectureである。画像では、同じ画像に異なるcropや色変換を施して複数のviewを作り、その間の一致を学ぶ方法が代表的である。DINO1 などがこの系統に属する。
この学習では、選んだデータ拡張に対して、表現は不変であるべきだという仮定をモデルに課している。どの変換に不変であるべきかは、データ拡張の設計によって決まる。画像分類では有効でも、cropによって含まれる物体数が変わった二つのviewを同じ表現へ近づけることが、物体検出にも適しているとは限らない。
中央は、入力信号そのものを再構成する構成である。たとえばMasked Autoencoders(MAE)2 は、画像のパッチを隠し、可視パッチの表現から隠れた画素を再構成する。ただし、画素を正確に再構成するための特徴が、そのまま物体の識別に役立つとは限らない。再構成では細かな模様や背景の違いも誤差に入るため、それらを復元する能力を高めることが、物体を識別しやすい表現の学習につながるかを確かめる必要がある。
右がJoint-Embedding Predictive Architecture(JEPA)である。こちらも片方の入力からもう片方を予測するが、誤差を計算する場所は表現空間である。I-JEPAでは追加情報 z に、予測したい領域の位置を用いる。
左の構成では、二つのviewをencoderに通した出力どうしを近づける。右の構成では、一方の出力をpredictorで変換してから、もう一方の出力と比較する。I-JEPAでは、見えている領域の表現と位置情報を手がかりに、predictorが隠れた領域の表現を予測する。
たとえば、鳥の胴体が見えていて頭が隠れている場合、胴体の表現から、隠した位置にある頭の表現を予測する。このとき、胴体と頭の表現を同じにする必要はない。領域ごとの違いを保ちながら、その関係を予測する学習であるため、局所的な形や配置の情報を表現に残せる可能性がある。
2.3 I-JEPAの着眼点と帰納バイアス
data2vec3 も、画素ではなく表現を予測する手法である。入力全体を処理した教師encoderの表現を正解とし、入力の一部を隠した側から予測する。
I-JEPAは、この表現空間での予測に、十分な広がりを持つ複数のtargetと、空間的に分布したcontextを組み合わせる。targetは予測する領域、contextは予測の手がかりとして見せる領域を指す。マスクの設計によって、何を手がかりに何を予測させるかを調整する。
また、複数のview間の表現を一致させる方法に比べ、人手で決めたデータ拡張の組み合わせへの依存が小さい。色変換やぼかしなどについて、「どの変換に不変であるべきか」を事前に仮定する部分を減らしている。公式実装では、入力画像の前処理にrandom resized cropと画素値の正規化を使う。4
I-JEPAにも、パッチ分割、位置埋め込み、マスクの形や大きさといった帰納バイアスはある。その設計では、どの変換を同一視するかという仮定を減らし、どの領域を手がかりに何を予測させるかという課題の設計に重点を置いている。
3. I-JEPAの学習問題と更新方法
3.1 全画像から作るターゲットと部分的なコンテキスト
I-JEPAは、context encoder、target encoder、predictorの三つで構成される。いずれもVision Transformer(ViT)5 を使う。ViTは画像を小さなパッチへ分割し、パッチ間のSelf-Attentionによって、各位置の表現へ周囲の情報を取り込むモデルである。
図2:I-JEPAの学習構成。上段は可視パッチからの予測、下段は全画像からの正解生成を表す。同じ色の領域に対応するパッチ表現を比較する。出典:Assran et al.(2023) Figure 3
前処理後の画像を y、パッチ数を N、encoderの特徴次元を d とする。target encoderを f_{\bar\theta} と書くと、正解側の表現は
S_y=f_{\bar\theta}(y)
=(s_{y,1},\ldots,s_{y,N}),
\qquad s_{y,j}\in\mathbb R^d
である。\bar\theta はtarget encoderのパラメータを表す。
予測する領域を M 個選び、領域の添字を i\in{1,\ldots,M} とする。図2下段の青・赤・黄の枠が、それぞれ一つの予測対象領域に対応する。i 番目の枠内にあるパッチの番号(添字)を集めた集合を B_i\subseteq{1,\ldots,N} とする。その領域の正解は、S_y から j\in B_i のベクトルを取り出したものである。
ここで順序が重要になる。target側では、先に全画像をencoderへ通し、その後で対象位置の出力を選ぶ。 領域を画像から切り出し、それだけをencoderへ渡す処理ではない。したがって s_{y,j} は、位置 j の画素だけでなく、Self-Attentionを通じた全体の文脈を含みうる。
context側では、前処理後の画像 y のパッチ格子上で、面積比85〜100%の正方形の領域を一つ選ぶ。これはcontext用のマスク領域を決める操作であり、画像を切り出してリサイズする前処理のrandom resized cropとは別である。この領域に含まれるパッチの添字集合を A とすると、実際に見せる集合 C は
C=A\setminus\bigcup_{i=1}^{M}B_i
である。つまり、どのtargetに含まれるパッチもcontextから除く。target同士は重なってもよいが、予測に用いる可視パッチとtargetは重ならない。
context encoder f_\theta は、この C に残ったパッチだけを処理する。画像全体の黒塗り部分まで計算するのではなく、可視パッチのトークンを入力するため、encoder側の計算を減らせる。元の画像内の位置は位置埋め込みで保持する。
標準的なマスク設定を整理すると次のようになる。6
対象
設定
target数 M
4個
各targetの面積比
画像の15〜20%
targetの縦横比
0.75〜1.5
除去前のcontext領域 A
面積比85〜100%、正方形
最終的なcontext C
A から全targetとの重複部分を除去
85〜100%は、除去前の領域の面積比である。最終的に画像のほぼ全部を見せるという意味ではない。また、4個のtargetには重なりがあるため、15〜20%を単純に4倍して可視率を求めることもできない。
図3:マスクの具体例。左から原画像、context、4個のtarget。暗い部分はその列で使わない領域を表す。出典:Assran et al.(2023)Figure 4
図3のcontext列では、見える領域が一つの小さな矩形に集中せず、画像内に分布している。I-JEPAの「一つのcontext block」は、大きな矩形から複数のtargetを除いたものであり、最終形状まで一つの穴のない矩形であるとは限らない。
3.2 位置を条件とするパッチ表現の予測
context encoderの出力を S_x、predictorを g_\phi とする。\phi はpredictorのパラメータである。predictorもViTで、contextのパッチ表現と予測位置を指定するmask tokenを、一つのトークン列として処理する。
まず、予測位置を指定するトークンを用意する。i 番目のtargetに含まれる各パッチ位置 j\in B_i に対し、
m_j=m_{\mathrm{mask}}+p_j
とする。m_{\mathrm{mask}} は全位置で共有する学習可能なmask vector、p_j は画像内の位置 j を表す位置埋め込みである。枠内のパッチ1個につき1個のトークンを用意し、それぞれの予測位置を指定する。隠れた画素の内容は、このトークンには含まれない。
入力時には、S_x をpredictor用の特徴次元へ射影し、可視パッチの位置埋め込みを加える。そのトークン列の後ろに {m_j}_{j\in B_i} を連結し、全体をTransformerのSelf-Attentionで処理する。これにより、mask tokenはcontextの情報を参照しながら、指定された位置の表現を予測する。
Transformerを通した後は、mask tokenに対応する出力だけを取り出し、教師と同じ特徴次元 d へ射影する。この処理までを含めて g_\phi と書くと、i 番目のtargetの予測は、
\hat S_y^{(i)}
=g_\phi\left(S_x\mid{m_j}_{j\in B_i}\right)
=\left(\hat s_{y,j}^{(i)}\right)_{j\in B_i}
となる。出力の各ベクトル \hat s_{y,j}^{(i)}\in\mathbb R^d が、教師側の同じ位置の表現 s_{y,j} に対応する。7
\mid は位置トークンによる条件付けである。同じ S_x を使っても、与える位置トークンの集合を変えると予測する領域が変わる。位置付きmask tokenをcontext表現と同じトークン列へ入れることで、この条件付けを実現している。
図2のpredictorの左右にある□は、次のように対応する。
図2の位置
対応するトークン・表現
predictor左の白(灰色)の□
context encoderが可視パッチから計算した表現 S_x
predictor左の色付き□
target内の各パッチ位置を指定するトークン m_j
predictor右の色付き□
各パッチの予測表現 \hat s_{y,j}^{(i)}。target領域全体では \hat S_y^{(i)}
右の色付き□は予測結果であり、図2下段のtarget encoderが作る正解表現と比較する。青・赤・黄は、それぞれ異なる予測領域を表す。同じcontext表現を使い、各targetの位置トークンを与えてpredictorを適用する。原論文の手順では、M 個のtargetに対してこの処理を M 回行う。
上付きの (i) は本記事で補った記号である。同じ位置 j が複数のtargetに含まれる場合でも、一緒に渡す位置トークンの集合が異なるため、予測をブロックごとに区別している。
学習時には、予測と正解を同じパッチ位置 j で比較し、位置ごとに二乗誤差を計算する。次節では、それらの誤差を足し合わせて一つの損失にする。集約するのは計算済みの誤差であり、予測表現や教師表現をブロック内で平均するわけではない。
predictorの内部特徴次元は標準で384であり、encoderより幅を狭くしている。たとえばViT-H encoderに対しては12層のpredictorを用いる。位置間の関係をSelf-Attentionで処理しつつ、encoderと同じ幅のネットワークを各targetについて動かす費用を避ける設計である。6:1
3.3 表現の回帰とEMAによるtarget encoderの更新
原論文の目的関数は、predictorが予測したパッチ表現と、target encoderが作った正解表現の二乗誤差である。1画像とそのマスクについて、次のように書ける。
\mathcal L(\theta,\phi;\bar\theta)
=\frac{1}{M}\sum_{i=1}^{M}\sum_{j\in B_i}
\left|
\hat s_{y,j}^{(i)}-\operatorname{sg}(s_{y,j})
\right|_2^2.
\operatorname{sg} はstop-gradientを表す。値はそのまま使うが、誤差からtarget encoder側へ勾配を流さない、という意味で本記事では明示している。原式に合わせ、ブロック間は 1/M で平均し、ブロック内のパッチについては和を取った。学習では画像とマスクをサンプルし、この損失のバッチ平均から、図2上段のcontext encoderのパラメータ \theta とpredictorのパラメータ \phi を更新する。8
ここには、別画像を負例として遠ざける対照損失も、画素を再構成する損失もない。target encoderが作る正解表現への予測誤差が、context encoderとpredictorを学習する信号になる。
一方、図2下段のtarget encoderのパラメータ \bar\theta は、上段のcontext encoderのパラメータ \theta の指数移動平均(EMA)で更新する。更新回数を t、勾配更新後のcontext encoderのパラメータを \theta_{t+1} とすると、
\bar\theta_{t+1}
=\tau_t\bar\theta_t+(1-\tau_t)\theta_{t+1}
である。\tau_t は更新前のtarget encoderのパラメータをどれだけ残すかを決める係数で、論文では0.996から1へ線形に増やす。target encoderのパラメータは、context encoderと同じ値で初期化する。6:2
この更新によって、context encoderの重みの変化をtarget encoderがゆっくり追いかける。target encoderは、その移動平均の重みを使って正解表現を作る。
!
原論文の式は二乗誤差だが、公開実装の src/train.py は、target encoderの出力を特徴次元でLayer Normalizationしたうえで smooth_l1_loss を使っている。本記事の数理的説明は原論文の二乗誤差に基づく。再実装時には、論文の式と公開コードの設定を区別する必要がある。9
事前学習後の評価には、原論文ではtarget encoderを使う。事前学習に [CLS] tokenは使わず、パッチ出力の平均プーリングから画像表現を作る。predictorは事前学習の予測課題を担うものであり、画像分類の出力器として使うものではない。10
4. 意味のある予測を支える条件
4.1 物体の形や配置を捉えるための帰納バイアス
I-JEPAの予測課題は、物体の形や部位の配置を手がかりに、隠れた領域の内容を捉える方向へ学習を促すと解釈できる。この帰納バイアスは、表現を予測することに加えて、比較的大きな領域を隠し、その位置を指定してパッチごとの表現を予測させる、という組み合わせから生じる。
隠れた領域を予測するときには、見えている物体のどの部分がどこへ続くのか、指定された位置にはどの部位がありそうか、といった関係が手がかりになる。さらに、正解にはtarget encoderが全画像を見て作った表現を用いる。context側には、全体の文脈を踏まえた、その位置の表現を予測することが求められる。
その際、細かな模様や色まで画素として復元することは目標に含めない。誤差を測るのはtarget encoderの表現なので、そこに残らない画素の違いまで当てる必要はない。物体の構造を捉える情報を予測しつつ、画素の細部を再現する負担を減らすのが、表現空間を予測先にする狙いである。入力の細部を見ないという制約はなく、予測に役立つ細部は利用できる。
この解釈は、原論文の予測表現の可視化とも整合する。別途学習した生成モデルで予測表現を画像化すると、物体の部位や姿勢に対応する構造が現れ、細かな模様や背景はサンプル間で変動する(第5.2節の図4)。11
予測先を表現にする効果は、原論文 Table 7で調べられている。予測対象をtarget encoderの表現にする場合と画素にする場合を比較した実験である。encoderはViT-L/16で、事前学習後に重みを固定し、ImageNetの1%ラベルで線形分類器を学習している。12
予測対象
事前学習epochs
1%ラベルでの線形評価Top-1(%)
target encoderの出力表現
500
66.9
画素
800
40.7
この比較では、表現を予測した設定の方が分類情報を線形に読み出しやすくなっている。ただし、事前学習のepoch数は同一ではなく、この表だけで形や配置の情報を直接検証しているわけでもない。形や配置を捉える帰納バイアスという見方は、予測課題の設計と可視化の観察を合わせた解釈である。
次節では、この予測課題を作るうえで、何を隠し、どの手がかりを残すかというマスク設計が持つ効果を見る。
4.2 マスクの大きさと文脈の広がり
教師表現を予測していても、手がかりと正解の組み合わせが悪ければ、認識に役立つ課題にはならない。
小さな欠損なら、近傍の模様の連続性だけで埋められる場合がある。一方、大きく隠しすぎると、見えている領域から正解を推測する手がかりが失われる。I-JEPAは、比較的大きなtargetを複数予測しつつ、画像内の広い範囲にcontextを残すことで、この両側を調整する。
これはマスクの設計意図であり、targetを選ぶ際に物体検出器や物体ラベルを使うわけではない。ランダムに選んだ大きな領域が、必ず一つの物体に対応するわけでもない。
原論文 Table 6は、表現予測を共通にしてマスク戦略を比較している。以下はViT-B/16を300 epochs事前学習し、encoderを固定してImageNetの1%ラベルで線形分類器を学習した結果である。12:1
マスク戦略
予測課題
平均可視率
Top-1精度(%)
multi-block
広いcontextから、複数の矩形領域を予測
25%
54.2
rasterized
画像の四分割のうち、一領域から残る三領域を予測
25%
15.5
block
一つの大きな矩形領域を、残りの領域から予測
40%
20.2
random
ランダムなパッチ集合を、残りのパッチから予測
40%
17.6
multi-blockとrasterizedは平均可視率が同じでも大きく異なる。さらに、可視率が40%の二方式より、25%のmulti-blockの方が高い。したがって、「見えるパッチを多くすればよい」という説明では足りない。
ただし、この比較ではtargetの個数、形状、配置、面積などが同時に変わる。差のすべてを、contextの空間的な広がりという一要因だけに帰すこともできない。
そこで付録Cの追加実験を見る。同じViT-B/16・300 epochs・1%線形評価で、targetを4個に固定したとき、面積比の範囲を7.5〜20%から15〜20%へ変えると19.2%から54.2%へ改善する。一方、さらに20〜25%へ大きくすると38.9%へ低下する。十分に大きいtargetは重要だが、大きいほどよいわけではない。 targetを広げるとcontextから除かれる領域も変わるため、必要なのは、予測の難しさと手がかりの両立である。13
文脈の使い方は教師側にも現れる。全画像を教師へ通してから出力を選ぶ場合と、各target領域だけを入力する場合を比較すると、ViT-H/16・300 epochs・1%線形評価で、前者が67.3%、後者が56.1%だった(原論文 Table 11)。13:1
これは第3.1節の処理順を裏づける実験である。I-JEPAが予測するのは、単にその場所だけを圧縮したベクトルではなく、全画像の中でその場所を見たときの表現である。予測する側には部分的な観測しか与えず、正解を作る側には全体を見せる。この情報の非対称性も、予測課題の一部になっている。
MSNとの共通点と予測対象の違い
Masked Siamese Networks(MSN)14 も、一部のパッチを隠したviewから、マスクしていないviewの表現を学ぶ手法である。画素を再構成せず、可視パッチだけを処理することや、正解側のencoderをEMAで更新することもI-JEPAと共通する。
MSNでは、各viewから画像単位の表現を作り、学習可能なprototype(代表ベクトル)への割当分布が一致するように学習する。cropや色変換、パッチの欠損によって見え方が変わっても、同じ画像に由来するviewには同じ割当を与えようとする。欠損に対して、画像単位の意味表現をそろえる設計である。
I-JEPAでは、contextと位置トークンから、指定された領域内の各パッチの表現を予測する。同じcontextでも、位置を変えれば別の領域が予測対象になる。contextの表現そのものをtargetの表現と一致させることは要求せず、predictorに位置に応じた対応関係を学ばせる。6:3
MSNは欠損に対する画像単位の不変性を学び、I-JEPAは位置に応じた領域間の予測を学ぶ、という違いがある。部分的な観測から表現を学ぶという発想を共有しつつ、何を一致させるかが異なる。この違いは、第4.1節で述べた、局所的な形や配置を捉える帰納バイアスにも関わると解釈できる。
4.3 表現崩壊の回避とpredictorの準最適性
すべての画像で予測も正解も同じ定数ベクトルになれば、損失はゼロになる。損失の式だけを見ると、このような表現崩壊も「正解」に見える。しかし、I-JEPAの一回の更新では、図2下段のtarget encoderが作る正解を固定したまま、上段のcontext encoderとpredictorを学習する。target encoderの重みを動かすのは、その後のEMAである。
この更新を理解する鍵になるのが、BYOL15で論じられた predictorの準最適性(near-optimality) である。準最適とは、今のcontext表現を使ってできる予測を、predictorがほぼできている状態を指す。この見方をI-JEPAへ当てはめて考える。
predictorが十分に学習できていても、context encoderが正解の予測に必要な情報を捨ててしまえば、それをpredictorだけで補うことはできない。target encoderが作る正解は固定されているので、context側が情報を減らすだけでは予測誤差は小さくならない。したがって、さらに誤差を減らすには、context encoderが予測に必要な情報を残す方向へ学習することが求められる。
その後、context encoderが学んだ重みを、EMAでtarget encoderへ少しずつ引き継ぐ。正解側が一度に大きく変わると、predictorは変化に追いつけず、十分に学習できた状態を保ちにくい。EMAは正解をゆっくり変化させ、predictorがその予測に追いつきやすくする役割を持つと考えられる。BYOL原論文はこの解釈を述べ、I-JEPA原論文もEMAによる更新が重要だったと報告している。15:18:1
このように、学習を動的系として捉え、predictorの準最適性を仮定すると、表現崩壊が避けられる理由を説明できる。
条件付き分散と情報保持の導出
ここでは第3.3節の、原論文に記載された二乗誤差を用いる。一つのtarget位置での誤差に注目するが、ブロック内の各パッチの誤差にも同じ分解を適用できる。target encoderのパラメータ \bar\theta は、この計算中は固定する。
教師表現を Z=s_{y,j}\in\mathbb R^d、予測に使える情報を U_\theta=(S_x,B_i,j) とする。U_\theta にはcontext表現と、位置トークンが指定する領域・パッチ位置を含めている。期待値は画像・マスク・予測位置のサンプリングについて取り、\mathbb E[|Z|_2^2]<\infty を仮定する。
その位置に対するpredictorの出力を a_\phi(U_\theta) とし、
R(\theta,\phi)
=\mathbb E\left[|Z-a_\phi(U_\theta)|_2^2\right]
と置く。計算を短く書くため、A=a_\phi(U_\theta)、\mu=\mathbb E[Z\mid U_\theta] とする。
\begin{aligned}
R(\theta,\phi)
&=\mathbb E[|Z-A|_2^2]\
&=\mathbb E[|(Z-\mu)+(\mu-A)|_2^2]\
&=\mathbb E[|Z-\mu|_2^2]
+\mathbb E[|\mu-A|_2^2]\
&\quad+2\mathbb E[(Z-\mu)^\top(\mu-A)].
\end{aligned}
\mu-A は U_\theta の関数なので、交差項は
\begin{aligned}
\mathbb E[(Z-\mu)^\top(\mu-A)]
&=\mathbb E\left[
\mathbb E[Z-\mu\mid U_\theta]^\top(\mu-A)
\right]\
&=0
\end{aligned}
となる。最後の等号には、\mathbb E[Z-\mu\mid U_\theta]=\mathbb E[Z\mid U_\theta]-\mu=0 を使った。したがって、
R(\theta,\phi)
=\mathbb E[|Z-\mu|_2^2]
+\mathbb E[|\mu-a_\phi(U_\theta)|_2^2]
である。右辺第1項はpredictorの選び方に依存しない。十分な表現能力を持つ理想的なpredictorなら、第2項をゼロにでき、a_\theta^\star(U_\theta)=\mu が最適になる。
この最適予測でも残る誤差を R_\theta^\star とする。k=1,\ldots,d を教師表現の特徴成分の添字とし、ノルムを成分ごとに展開すると、
\begin{aligned}
R_\theta^\star
&=\mathbb E[|Z-\mu|_2^2]\
&=\mathbb E\left[
\sum_{k=1}^{d}(Z_k-\mu_k)^2
\right]\
&=\mathbb E\left[
\sum_{k=1}^{d}\mathbb E[(Z_k-\mu_k)^2\mid U_\theta]
\right]\
&=\mathbb E\left[
\sum_{k=1}^{d}\operatorname{Var}(Z_k\mid U_\theta)
\right]
\end{aligned}
となる。また、
R(\theta,\phi)-R_\theta^\star
=\mathbb E\left[
|a_\phi(U_\theta)-a_\theta^\star(U_\theta)|_2^2
\right]
\geq 0
が、最適predictorからの超過誤差である。準最適性は、この超過誤差が小さい状態として捉えられる。
次に、contextの情報を捨てた場合を比較する。位置指定は保ったまま、情報を持つ入力 U から、情報を減らした入力 V=h(U) を作るとする。\mu_U=\mathbb E[Z\mid U]、\mu_V=\mathbb E[Z\mid V] と置く。V は U の関数なので、\mu_U-\mu_V も U の関数である。このため、
\begin{aligned}
\mathbb E[(Z-\mu_U)^\top(\mu_U-\mu_V)]
&=\mathbb E\left[
\mathbb E[Z-\mu_U\mid U]^\top(\mu_U-\mu_V)
\right]\
&=0
\end{aligned}
となる。この交差項を使って誤差を分解すると、
\begin{aligned}
R_V^\star
&=\mathbb E[|Z-\mu_V|_2^2]\
&=\mathbb E[|(Z-\mu_U)+(\mu_U-\mu_V)|_2^2]\
&=R_U^\star+\mathbb E[|\mu_U-\mu_V|_2^2]\
&\geq R_U^\star
\end{aligned}
を得る。入力から情報を捨てるだけでは、最適predictorでも残る誤差を減らせない。 捨てた情報によって教師の条件付き平均が変わる場合、右辺第2項は正になる。これが、予測に使えていた情報を失う方向に抵抗が生じる、という説明の根拠である。予測に関係しない情報を捨てた場合には、第2項がゼロのままでもよい。
この最適値の議論をcontext encoderの更新方向へつなげるには、追加の仮定が必要になる。条件付き平均を実現する最適predictorのパラメータを、\theta の関数 \phi^\star(\theta) として選べるとする。必要な微分可能性、期待値と微分の交換、最適点の一次条件を仮定すると、連鎖律から
\begin{aligned}
\nabla_\theta R_\theta^\star
&=\left.\nabla_\theta R(\theta,\phi)\right|_{\phi=\phi^\star(\theta)}\
&\quad+
\left(\frac{\partial\phi^\star(\theta)}{\partial\theta}\right)^\top
\underbrace{\nabla_\phi R(\theta,\phi^\star(\theta))}_{=0}\
&=\left.\nabla_\theta R(\theta,\phi)\right|_{\phi=\phi^\star(\theta)}
\end{aligned}
となる。\partial\phi^\star(\theta)/\partial\theta は、最適predictorのパラメータがcontext encoderの変化にどう依存するかを表すJacobianである。predictorが最適なので、その変化を介する項は一次条件によって消える。残るのは、predictorのパラメータを固定し、その入力を通してcontext encoderへ逆伝播する勾配である。これが、最適predictorの下ではcontext側の更新を、期待条件付き分散を下げる更新として読める理由になる。
この等式はpredictorが厳密に最適な場合のものである。準最適なpredictorで同じ更新方向を近似すると読むには、近似誤差が勾配も大きく変えないことが必要になる。小さい予測誤差だけで、更新方向の近さや学習全体の安定性まで保証されるわけではない。
また、崩壊した不動点が存在することと、その不動点が周囲の状態を引きつけることは別である。両encoderが既に同じ定数表現になった状態は、不動点として残りうる。BYOL原論文も、最適predictorの下でこうした平衡点が不安定になるという見方を仮説として提示している。ここでは、その情報保持の議論をI-JEPAへ適用した解釈を述べており、ViTやEMAを含む学習全体の安定性を証明したものではない。
BYOLでの導出と更新ダイナミクスについては、BYOLの解説記事でも説明している。
5. 実験結果と評価の限界
5.1 線形評価が示す分類表現の読み出しやすさ
I-JEPAは、ImageNet-1Kの線形評価でMAEを上回り、encoderを追加学習しなくても分類情報を取り出しやすい表現を獲得している。 この点を、学習済みencoderの重みを固定し、その出力の上に線形分類器だけを学習する評価で確かめる。
次表は原論文 Table 1から必要な行を抜粋したものだ。事前学習にはImageNet-1Kの画像をラベルなしで使い、線形分類器の学習には全学習ラベルを使う。epoch数は事前学習の回数である。16
手法
encoder
事前学習epochs
Top-1精度(%)
MAE
ViT-H/14
1600
77.2
I-JEPA
ViT-H/14
300
79.3
data2vec
ViT-L/16
1600
77.3
I-JEPA
ViT-L/16
600
77.5
iBOT17
ViT-L/16
250
81.0
I-JEPA
ViT-H/16(448解像度)
300
81.1
ViTのB・L・HはそれぞれBase・Large・Hugeを表し、/14 や /16 はパッチの一辺の画素数を表す。本論文のI-JEPAは原則224 × 224で事前学習し、表の最後の行は448 × 448を使う。
中心となるのは、同じViT-H/14を使ったMAEとの比較である。MAEは1600 epochsの事前学習で77.2%、I-JEPAは300 epochsで79.3%だった。encoderを更新しない条件で、I-JEPAは少ない事前学習epochsで2.1ポイント高い精度を得ている。 これは、I-JEPAが事前学習の段階で、分類に必要な違いを線形に取り出しやすい表現を作っていることを支持する。
data2vecとのViT-L/16の比較では、精度は77.3%と77.5%で近いが、事前学習はそれぞれ1600 epochsと600 epochsである。ここでもI-JEPAは、少ない事前学習epochsで同程度の線形評価の精度を得ている。
さらに、高解像度のI-JEPAは81.1%に達し、複数view間の不変性を学ぶiBOTの81.0%に並ぶ。こちらはモデルサイズと解像度が異なる比較だが、I-JEPAでもこの水準の線形分類性能に到達できることを示している。
ラベル数とfine-tuningによる評価条件の違い
ラベルを1%に減らした比較では、ViT-H/14のI-JEPAは73.3%、MAEは71.5%だった。これは原論文 Table 2の評価であり、各手法についてfine-tuningまたはlinear probingのよい方を採用している。I-JEPAは50 epochsのfine-tuningを行う。第4章のアブレーションで使った「1%ラベルの線形評価」とは、encoderを更新するかどうかが違う。16:1
全ラベルでfine-tuningすると、448 × 448でI-JEPAのViT-H/16が87.1%、MAEのViT-H/14が87.8%である。事前学習はそれぞれ300、1600 epochsで、パッチサイズも異なる。線形評価での優劣が、そのまま維持されるわけではない。18
線形評価は、事前学習で得た表現から分類情報を取り出しやすいかを調べる。fine-tuningは、ラベルでencoderも調整した後の性能を調べる。両者を分けることで、事前学習した表現から線形に読み出せる情報と、教師ありの追加学習後の分類性能を区別できる。
encoderを固定したまま高い分類性能を得られることが、I-JEPAの表現の有用性を支えている。 次節では、分類情報に加えて、物体数や距離などの情報も読み出せるかを見る。
5.2 分類情報と画像内の構造に関する情報の両立
I-JEPAは、分類情報を読み出しやすい表現を得ると同時に、物体数や距離などの情報も保持している。 encoderを固定した評価と、predictorの予測表現の可視化から、この特徴を見る。
原論文 Table 4では、encoderを固定し、CLEVRの物体数と距離に関する課題を線形に読み出している。CLEVR/Countは物体数の分類、CLEVR/Distは最も近い物体までの距離を区間に分ける分類である。以下は精度(%)の抜粋である。1920
手法
encoder
CLEVR/Count
CLEVR/Dist
MAE
ViT-H/14
90.5
72.4
I-JEPA
ViT-H/14
86.7
72.4
DINO
ViT-B/8
86.6
53.4
iBOT
ViT-L/16
85.7
62.8
表の設定では、I-JEPAの距離分類は72.4%で、DINOの53.4%、iBOTの62.8%を上回り、MAEと同じ精度を得ている。第5.1節の結果と合わせると、MAEより高い線形分類精度を得ながら、距離情報もMAEと同じ水準で読み出せている。一方、物体数ではMAEが上回り、DINOとの差も0.1ポイントである。I-JEPAの特徴は、分類情報とこうした構造に関する情報が両方使える点にある。
predictorの予測表現を調べるため、原論文ではRCDM(Representation-Conditioned Diffusion Model)21 による可視化も行っている。これは、別途学習した拡散decoderに、predictorの出力を条件として与えて画像を生成する方法である。11:1
図4:predictorの出力表現の可視化。左右それぞれの例で、左から元画像、context、4個の生成サンプルを並べている。緑枠が予測対象領域。出典:Assran et al.(2023)Figure 6
鳥の例では背中に対応する領域に鳥の身体らしい構造が、車の例では車体の上部に対応する構造が現れる。一方、細部の模様や色合いはサンプル間で変動する。共通して現れる部位や姿勢は、predictorがその位置の物体構造を捉えた表現を予測していることを示唆する。
この結果は、第4.1節で述べた、物体の形や配置を捉えながら、画素の細部を再現する負担を減らすという設計意図を支持する観察である。
可視化の手順と読み取りの範囲
まず、学習済みのcontext encoderとpredictorを固定する。次に、predictorのパッチ出力を平均プーリングした表現を条件として、画像へ戻す拡散decoderを別途学習する。図4の緑枠内は、そのdecoderへ異なる乱数を与えて得たサンプルである。可視化の対象は、target encoderそのものではなくpredictorの予測表現である。
このdecoderはI-JEPAの事前学習には含まれない。また、表示される画像にはdecoderの能力や事前知識も反映される。サンプル間で変わる情報が元の表現に存在しないと証明したものではなく、予測表現に残る構造を探るための定性的な手がかりとして読む。
5.3 反復回数の削減による事前学習の効率化
I-JEPAは、有用な表現を得るまでの反復回数を減らすことで、事前学習全体の計算コストを抑えている。
target encoderが全画像を処理する追加コストがあるため、原論文のMAEとの比較では、1 iterationあたりの処理時間は約7%長い。一方、必要なiterationは約5分の1だった。一回の処理にかかる追加コストより、必要な反復回数が減る効果の方が大きく、総学習コストが下がっている。22
構成上も、context encoderには可視パッチだけを渡し、predictorには幅の狭いViTを使う。複数viewを処理する手法に対しては、target encoderが一つのviewだけを処理すればよい点も効率につながる。論文では、ImageNet上のViT-H/14を16台のA100で72時間未満で事前学習できたと報告している。22:1
第5.1節の、少ない事前学習epochsで高い線形分類精度を得る結果と合わせると、何を予測させるかという課題の設計は、有用な表現を得るまでの計算コストにも関わることが分かる。
計算量の比較条件と適用範囲
学習時間は、論文で使われたモデル・データ・実装における結果である。複数viewを使う手法では、1画像あたりに処理する入力の数も異なる。epoch数、1 stepの処理時間、GPU台数を掛けたGPU-hours、実時間は、それぞれ区別して比較する必要がある。
また、計算効率の改善が、すべてのタスクでの優位性を意味するわけではない。転移分類のiNaturalist18では、線形評価がI-JEPAのViT-H/14で47.6%、DINOのViT-B/8で55.9%、iBOTのViT-L/16で57.3%だった。16:2
I-JEPAは、異なる領域の間に予測に使える依存関係があることを利用する。たとえば、周囲から予測しにくい微小な異常が重要な課題では、大きなブロックの予測と抽象化が適切かどうかを別途評価する必要がある。これは本論文が実験した失敗例ではなく、予測課題と下流で必要な情報の違いから生じる検討事項である。
本論文の対象は静止画像内の領域間の関係であり、時間的な変化、行動の結果、因果介入を学習・評価したものではない。
6. さいごに
I-JEPAから持ち帰りたいのは、データの構造を手がかりに、下流で必要な情報を学べる予測課題(pretext task)を考えるという視点である。
I-JEPAの設計が促すのは、物体の形や配置を捉える抽象化だと解釈できる。見えている胴体から隠れた頭の表現を予測するときには、部位ごとの違いを保ちながら、そのつながりや配置を捉えることが手がかりになる。正解を表現空間に置くことで、そこに含まれない画素の細部を再現する負担も減らせる。予測する対象と与える文脈を選ぶことが、こうした抽象化を促す帰納バイアスになる。
この個性は、事前学習済みcheckpointを選ぶときの参考にもなる。第5章で見た分類情報と物体数・距離の情報の両立は、I-JEPAを選ぶ理由の一つになりうる。何を予測する課題で学んだかを知ることは、その表現が自分の用途に合うかを考える手がかりになる。
自分で事前学習を行う場合には、この対応を予測課題の設計に反映することになる。I-JEPAが自然画像にある部位のつながりや配置を利用したように、別のドメインでも、そのデータの成り立ちや構造から予測に使える関係を考えたい。その関係を学ぶことで下流に必要な情報が残るよう、データの背景に合ったpretext taskを設計することが大切である。
脚注
Mathilde Caron et al., Emerging Properties in Self-Supervised Vision Transformers, ICCV 2021。DINOは、異なるviewに対する自己蒸留を用いる。 ↩︎
Kaiming He et al., Masked Autoencoders Are Scalable Vision Learners, CVPR 2022。 ↩︎
Alexei Baevski et al., data2vec: A General Framework for Self-supervised Learning in Speech, Vision and Language, ICML 2022。全入力から得る文脈を含む教師表現を、マスクされた入力から予測する。 ↩︎
公式実装の画像変換とViT-H/14の標準設定。この設定ではcolor distortion、Gaussian blur、horizontal flipは無効だが、random resized cropは用いる。 ↩︎
Alexey Dosovitskiy et al., An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale, ICLR 2021。 ↩︎
I-JEPA原論文第3節および付録A.1。ミニバッチ処理では、同じGPU上で扱うcontextのパッチ数、targetのパッチ数をそれぞれ揃える処理も行う。 ↩︎ ↩︎ ↩︎ ↩︎
公式実装のVisionTransformerPredictor。forwardでトークンの連結、Transformerによる処理、予測出力の取り出しを確認できる。 ↩︎
I-JEPA原論文第3節「Loss」。二乗ノルムをブロック内で加算する原式と、教師をEMAで更新する手順に基づく。 ↩︎ ↩︎
公式実装のsrc/train.py。forward_targetでは勾配計算を止めた教師出力にlayer_normを適用し、loss_fnではsmooth_l1_lossを使う。 ↩︎
I-JEPA原論文付録A。下流の線形評価では、最終層の平均プーリングだけでなく、最後の4層をそれぞれ平均プーリングして連結した表現や、分類head前のBatch Normalizationの有無も比較し、よい設定を採用する。 ↩︎
I-JEPA原論文第8節および付録E。predictor出力を平均プーリングし、別途学習したRCDM decoderの条件として可視化する。 ↩︎ ↩︎
I-JEPA原論文第9節とTables 6–7。両実験とも1%ラベルの線形評価だが、マスク戦略はViT-B/16、予測空間はViT-L/16の実験である。 ↩︎ ↩︎
I-JEPA原論文付録C、Tables 8–11。target面積、context面積、target個数、教師へのマスク位置を比較している。 ↩︎ ↩︎
Mahmoud Assran et al., Masked Siamese Networks for Label-Efficient Learning, ECCV 2022。第3節に、viewのマスク、prototypeへの割当分布の整合、target encoderのEMA更新を記載。 ↩︎
Jean-Bastien Grill et al., Bootstrap Your Own Latent: A New Approach to Self-Supervised Learning, NeurIPS 2020。第3.2節では最適predictorと条件付き分散から崩壊点の不安定性を仮説として説明し、付録Hで更新方向との関係、付録Iで準最適性に関する実験を示す。 ↩︎ ↩︎
I-JEPA原論文第5節、Tables 1–3および付録A.2。Table 1は全ラベルの線形評価、Table 2は1%ラベルで各手法に適した適応方法を採用した比較、Table 3は転移先での線形評価である。 ↩︎ ↩︎ ↩︎
Jinghao Zhou et al., iBOT: Image BERT Pre-Training with Online Tokenizer, ICLR 2022。異なるview間の自己蒸留とパッチ単位の学習を組み合わせる。 ↩︎
I-JEPA原論文付録D、Table 15。I-JEPAでは全ImageNetラベルで50 epochsのfine-tuningを実施する。 ↩︎
I-JEPA原論文第6節、Table 4。評価時の入力変換も付録A.2に記載され、CLEVRでは物体を除いてしまうrandom cropを避けている。 ↩︎
VTABの公式CLEVR実装では、Countは3〜10個の8クラス、Distは最近傍物体の距離を6区間に分けた分類として定義されている。 ↩︎
Florian Bordes, Randall Balestriero, Pascal Vincent, High Fidelity Visualization of What Your Self-Supervised Representation Knows About, TMLR 2022。 ↩︎
I-JEPA原論文第7節およびAbstract。所要時間は論文の実験報告であり、本記事で事前学習を追試した結果ではない。 ↩︎ ↩︎
1 like
Google's harness2 Rewrites AI Agent Setup
A short look at google-research/harness2, an open source research project that rewrites the setup around a fixed AI model so it does better on work tasks. Code and docs: google-research/harness2
4 likes · 351 views
ゼロショットで予測できる TimesFM 3.0 を BigQuery で動かしてみた
はじめに
BigQuery で TimesFM 3.0 が使えるという紹介ブログを見て、少し興味を持ちました。ゼロショットで予測できるモデルっぽいから、使えるなら使ってみたいかなあ、くらいの気持ちです。
そこで、BigQuery の公開データを使って TimesFM 3.0 を動かし、前のバージョンの 2.5 と比べたり、祝日や天気を渡したりしてみました。自分の備忘録も兼ねて、分かったことをまとめます。
TL;DR
TimesFM 3.0 とは
日付順の数字から、この先の値と予測区間を返す
自分のデータで学習させずに使える(ゼロショット)
どう書くか
AI.FORECAST 関数に表を渡す
予測の手がかりになる列(共変量)は future_covariate_cols などで指定する
2.5→3.0 で何が変わったのか
共変量と多変量を扱えるようになった
1 つの系列の予測は、今回の条件では 2.5 のほうが精度が少し高かった
天気を渡すと予測は動いたが、今回の条件では精度はほぼ変わらなかった
プロジェクトにどう組み込むか
3.0 を商用で使えるのは BigQuery だけだが、まだ Preview
本番に入れるなら GA 待ちで、1 つの系列の予測なら BigQuery で 2.5 を今から使える
TimesFM 3.0 とは
TimesFM は、Google Research が作った時系列予測のモデルです。売上やアクセス数のような、日付順に並んだ数字(系列)を渡すと、この先の値と、その幅(予測区間)を返します。
Google が大量の時系列データで事前に学習させてあるので、自分のデータで学習させずに(ゼロショットで)使えます。ただ、予測のたびに過去の系列を渡す必要があり、天気や祝日のような表にない情報は、渡さない限り考慮されません。
BigQuery にもともとある予測モデル ARIMA_PLUS と並べると、こうなります。
ARIMA_PLUS
TimesFM
使う前の準備
CREATE MODEL で自分のデータを学習させる
なし
予測のときに渡すもの
予測する期間
過去の系列(必要なら、天気などの共変量も)
自分のデータの扱い
学習でモデルに覚えさせる
予測のたびに読むだけで、覚えない
BigQuery では 2.5 と 3.0 を選べます。
どう書くか(AI.FORECAST)
AI.FORECAST の引数の一覧は、AI.FORECAST Syntax にあります。
書き方は 2 通りあり、共変量を渡せるのは Multivariate だけです。
1 つの系列を予測する
共変量を渡す
リファレンスの節
Univariate
Multivariate
予測する列
data_col(1 列)
target_cols(リスト。複数書くと、複数の列を同時に予測する多変量になる)
共変量
渡せない
future_covariate_cols、past_covariate_cols
model
2.5 も 3.0 も選べる(省くと 2.5)
'TimesFM 3.0' を必ず書く
また、今回使ったのは、次の BigQuery の公開データです。
bigquery-public-data.new_york.citibike_trips:Citi Bike(ニューヨークのシェアサイクル)の利用記録
1 回の利用が 1 行で、2013-07-01〜2016-09-30 のデータがある
bigquery-public-data.noaa_gsod:NOAA(米国海洋大気庁)の気象データ
観測所ごと・日ごとの天気
例はどちらも、実績が分かっている 2016 年 9 月を隠して予測しています。精度は「2.5→3.0 で何が変わったのか」で見ます。
1 つの系列を予測する
今回は、8 月末までの利用回数から、9 月の利用回数を予測しました。Citi Bike の利用記録を日ごとの利用回数(daily)にまとめ、AI.FORECAST に渡しました。
SELECT *
FROM AI.FORECAST(
TABLE daily, -- 予測に使う過去のデータ(1日1行)
data_col => 'num_trips', -- 予測する列
timestamp_col => 'dt', -- 日付の列
model => 'TimesFM 3.0', -- 省くと TimesFM 2.5
horizon => 30 -- 何点先まで予測するか(1日1行なので日数)
);
SQL 詳細WITH daily AS ( -- 日ごとの利用回数(8/31 まで)
SELECT DATE(starttime) AS dt, COUNT(*) AS num_trips
FROM bigquery-public-data.new_york.citibike_trips
WHERE starttime IS NOT NULL AND starttime < '2016-09-01' -- 9月は答え合わせに使うので隠す
GROUP BY dt
)
SELECT *
FROM AI.FORECAST(
TABLE daily,
data_col => 'num_trips',
timestamp_col => 'dt',
model => 'TimesFM 3.0',
horizon => 30
);
実行結果(抜粋)
forecast_timestamp
forecast_value
confidence_level
prediction_interval_lower_bound
prediction_interval_upper_bound
ai_forecast_status
2016-09-01 00:00:00
56191.88
0.95
49976.22
61698.79
''
2016-09-02 00:00:00
53535.74
0.95
44921.19
60750.70
''
2016-09-03 00:00:00
42717.00
0.95
32562.72
51680.64
''
2016-09-04 00:00:00
40867.40
0.95
28283.59
51935.86
''
forecast_value が予測の中央値、prediction_interval_lower_bound と prediction_interval_upper_bound が 95%(confidence_level)の予測区間です。ai_forecast_status は、成功なら空文字です(値は小数 2 桁に丸めています)。
共変量を渡す
共変量は、予測の手がかりとして一緒に渡す列です。今回は、天気(降水量・平均気温)を共変量にして、9 月の利用回数を予測しました。
日ごとの利用回数(num_trips)に、セントラルパークの降水量(prcp)と平均気温(temp)の列を足した表(daily_with_weather)を作り、AI.FORECAST に渡しました。daily_with_weather の 8 月末から 9 月初めの行は、次のとおりです。
dt
num_trips
prcp(インチ)
temp(華氏)
2016-08-30
57,405
0.0
77.0
2016-08-31
57,214
0.0
78.9
2016-09-01
NULL
0.04
73.9
2016-09-02
NULL
0.46
72.6
SELECT *
FROM AI.FORECAST(
TABLE daily_with_weather, -- 過去の行と、利用回数を NULL にした予測する日の行
target_cols => ['num_trips'], -- 予測する列
timestamp_col => 'dt', -- 日付の列
future_covariate_cols => ['prcp', 'temp'], -- 予測する期間の値も分かっている列
model => 'TimesFM 3.0', -- 共変量を渡すときは 3.0 が必須
horizon => 30, -- 何点先まで予測するか(9月の30日間)
context_window => 1024 -- 予測に使う過去の点数
);
ちなみに、今日までの値しか分からない情報には、future_covariate_cols ではなく past_covariate_cols を使います。
SQL 詳細WITH daily AS ( -- 日ごとの利用回数
SELECT DATE(starttime) AS dt, COUNT(*) AS num_trips
FROM bigquery-public-data.new_york.citibike_trips
WHERE starttime IS NOT NULL
GROUP BY dt
),
weather AS ( -- セントラルパークの観測所の日ごとの天気
SELECT
DATE(CAST(year AS INT64), CAST(mo AS INT64), CAST(da AS INT64)) AS dt,
IF(prcp = 99.99, 0.0, prcp) AS prcp, -- 降水量(インチ)。欠測は 0 とみなす
temp -- 平均気温(華氏)
FROM bigquery-public-data.noaa_gsod.gsod*
WHERE _TABLE_SUFFIX BETWEEN '2013' AND '2016'
AND stn = '725053' AND wban = '94728'
),
daily_with_weather AS ( -- 利用回数に天気を添えた入力(9/30 まで)
SELECT
d.dt,
IF(d.dt < DATE '2016-09-01', d.num_trips, NULL) AS num_trips, -- 予測する9月は NULL にする
w.prcp,
w.temp
FROM daily AS d
JOIN weather AS w ON w.dt = d.dt
)
SELECT *
FROM AI.FORECAST(
TABLE daily_with_weather,
target_cols => ['num_trips'],
timestamp_col => 'dt',
future_covariate_cols => ['prcp', 'temp'],
model => 'TimesFM 3.0',
horizon => 30,
context_window => 1024
);
実行結果(抜粋)
dt
num_trips.value
num_trips.prediction_interval_lower_bound
num_trips.prediction_interval_upper_bound
time_series_type
confidence_level
ai_forecast_status
2016-09-01 00:00:00
53410.43
45873.04
59963.85
forecast
0.95
''
2016-09-02 00:00:00
49312.29
39320.58
58367.76
forecast
0.95
''
2016-09-03 00:00:00
43553.52
31200.80
54973.15
forecast
0.95
''
2016-09-04 00:00:00
44209.27
30954.60
56406.93
forecast
0.95
''
num_trips は 1 つの列で、中に予測の中央値(value)と予測区間の上下限が入っています。表では、取り出すときの書き方に合わせて num_trips.value のように分けて示しました。日付の列は、1 つの系列では forecast_timestamp になりますが、こちらは timestamp_col に渡した dt のまま返ります(値は小数 2 桁に丸めています)。
注意点
future_covariate_cols を使うときは、リファレンスでは context_window(予測に使う過去の点数)を「表の行数 − horizon」以下にするとされています
今回は日ごとの表が 1,200 行弱、horizon が 30
3.0 の既定の 2,048 ではこの決まりを満たさないので 1,024 に設定
context_window は、3.0 では 32 の倍数(64〜2,048)です
horizon の上限は 2.5 が 10,000、3.0 が 1,024 です
target_cols を使うと、出力は予測する列ごとの STRUCT になり、予測値は num_trips.value で取り出します
費用は SQL 1 本あたり約 250MB〜1GB の読み込みです。2026-12-01 からは、3.0 にトークン課金が加わります(AI.FORECAST Pricing)。
2.5→3.0 で何が変わったのか
精度は上がるのか
「1 つの系列を予測する」の SQL で、2016 年 9 月を 2.5 と 3.0 に予測させ、実績と比べました。精度は、実績とのずれで見ます。ずれは「予測と実績の差の絶対値 ÷ 実績」で算出し、値が小さいほど精度が高いことになります。
2016 年 9 月の実績と予測(8 月までのデータで予測)
2 本の予測はほとんど重なりました。急に落ちた日(9/1、9/19、9/30)はいずれも平日で、どちらのモデルもずれが大きくなっています。ずれを平均すると、次のとおりです。
2.5
3.0
30 日間の平均
18.4%
19.9%
平日(22 日)の平均
20.3%
20.7%
土日(8 日)の平均
13.1%
17.7%
紹介ブログでは、3.0 は共変量なしでも 2.5 を含む他のモデルと同等以上とされていますが、これは多くのデータセットでの平均順位として書かれています。今回比べたのは 1 つのデータの 1 か月分で、言えるのは、このデータの 9 月では 2.5 のほうが精度が少し高かった、というところまでです。
2.5 と 3.0 の予測を実績と並べる SQLWITH daily AS ( -- 日ごとの利用回数
SELECT DATE(starttime) AS dt, COUNT(*) AS num_trips
FROM bigquery-public-data.new_york.citibike_trips
WHERE starttime IS NOT NULL
GROUP BY dt
),
train AS (
SELECT * FROM daily WHERE dt < '2016-09-01' -- 9月を隠す
),
fc_25 AS (
SELECT * FROM AI.FORECAST(TABLE train, data_col => 'num_trips', timestamp_col => 'dt', model => 'TimesFM 2.5', horizon => 30)
),
fc_30 AS (
SELECT * FROM AI.FORECAST(TABLE train, data_col => 'num_trips', timestamp_col => 'dt', model => 'TimesFM 3.0', horizon => 30)
)
SELECT
a.dt,
a.num_trips AS actual,
ROUND(f25.forecast_value) AS forecast_25, -- forecast_value は予測の中央値
ROUND(f30.forecast_value) AS forecast_30,
-- その日の予測が実績から何%ずれたか
ROUND(ABS(f25.forecast_value - a.num_trips) / a.num_trips * 100, 1) AS error_pct_25,
ROUND(ABS(f30.forecast_value - a.num_trips) / a.num_trips * 100, 1) AS error_pct_30,
-- 30日分の平均を全行に入れる(OVER () は行をまとめずに全体で集計する)
ROUND(AVG(ABS(f25.forecast_value - a.num_trips) / a.num_trips) OVER () * 100, 1) AS avg_error_pct_25,
ROUND(AVG(ABS(f30.forecast_value - a.num_trips) / a.num_trips) OVER () * 100, 1) AS avg_error_pct_30
FROM daily AS a
-- 予測の日付は時刻付きで返るので、日付に直して実績とつなぐ
JOIN fc_25 AS f25 ON DATE(f25.forecast_timestamp) = a.dt
JOIN fc_30 AS f30 ON DATE(f30.forecast_timestamp) = a.dt
ORDER BY a.dt;
共変量を渡せるようになった
BigQuery で共変量と多変量を扱えるのは、「どう書くか」の表のとおり 3.0 だけです。紹介ブログには、アイスクリームの販促予定を渡すと、販促日ごとに売上が約 2 割増える予測になる、という例があります。公開データでも同じような結果になるかを、祝日と天気を渡して試しました。ここからは、予測がどれだけ動いたかを回数で、精度をずれ(%)で見ます。
祝日を渡してみる
会員の利用回数で 2016 年 7 月の前半(7/1〜14)を隠し、その日が独立記念日か(1 か 0)を渡す場合と渡さない場合を比べました。2016 年の独立記念日は 7/4 です。
2016 年 7 月の会員の利用回数と、独立記念日を渡す・渡さない予測(TimesFM 3.0)
7/4 の実績は 22,349 回で、普段の平日の半分以下でした。予測は、独立記念日を渡さないと 46,555 回、渡しても 42,234 回で、どちらも実績の 2 倍前後のままでした。参照した過去 1,024 日に独立記念日は 2 回しかなく、例が少なかったのだと考えています。
!
SQL は会員と非会員を同時に予測しています(多変量)が、グラフと数字は会員のみの結果です。
独立記念日を渡す・渡さない予測を実績と並べる SQLWITH daily AS ( -- 日ごとの会員・非会員の利用回数(7/14 まで)
SELECT
DATE(starttime) AS dt,
COUNTIF(usertype = 'Subscriber') AS subscriber_trips, -- 会員の利用回数
COUNTIF(usertype = 'Customer') AS casual_trips -- 非会員の利用回数
FROM bigquery-public-data.new_york.citibike_trips
WHERE starttime IS NOT NULL AND starttime < '2016-07-15'
GROUP BY dt
),
holidays AS (
-- 独立記念日(土曜は前日の金曜に振り替えた日)
SELECT dt FROM UNNEST([ -- 並べた日付を1行ずつの表にする
DATE '2013-07-04', DATE '2014-07-04', DATE '2015-07-03', DATE '2016-07-04'
]) AS dt
),
with_flag AS ( -- 祝日を渡す側の入力(7/14 までの全行を持つ)
SELECT
d.dt,
-- 予測したい日(7/1 以降)は NULL にする
IF(d.dt < DATE '2016-07-01', d.subscriber_trips, NULL) AS subscriber_trips,
IF(d.dt < DATE '2016-07-01', d.casual_trips, NULL) AS casual_trips,
-- 祝日は先の日付でも分かるので全期間に値を入れる
IF(h.dt IS NULL, 0.0, 1.0) AS is_holiday
FROM daily AS d
LEFT JOIN holidays AS h ON d.dt = h.dt -- 祝日でない日は h.dt が NULL になる
),
without_flag AS ( -- 祝日を渡さない側の入力(6/30 まで、祝日の列はない)
SELECT dt, subscriber_trips, casual_trips
FROM daily
WHERE dt < DATE '2016-07-01'
),
fc_with AS (
SELECT *
FROM AI.FORECAST(
TABLE with_flag,
target_cols => ['subscriber_trips', 'casual_trips'], -- 同時に予測する列
timestamp_col => 'dt',
future_covariate_cols => ['is_holiday'], -- 予測する期間の値も分かっている列
model => 'TimesFM 3.0',
horizon => 14,
context_window => 1024 -- 予測に使う過去の点数
)
),
fc_without AS (
SELECT *
FROM AI.FORECAST(
TABLE without_flag,
target_cols => ['subscriber_trips', 'casual_trips'],
timestamp_col => 'dt',
model => 'TimesFM 3.0',
horizon => 14,
context_window => 1024
)
)
SELECT
a.dt,
f.is_holiday,
a.subscriber_trips AS actual_subscriber,
ROUND(fo.subscriber_trips.value) AS without_flag_subscriber, -- 予測値と区間の上下限をまとめた列から予測値を取り出す
ROUND(fw.subscriber_trips.value) AS with_flag_subscriber,
a.casual_trips AS actual_casual,
ROUND(fo.casual_trips.value) AS without_flag_casual,
ROUND(fw.casual_trips.value) AS with_flag_casual
FROM daily AS a
JOIN with_flag AS f ON f.dt = a.dt
JOIN fc_without AS fo ON DATE(fo.dt) = a.dt
JOIN fc_with AS fw ON DATE(fw.dt) = a.dt
WHERE fo.time_series_type = 'forecast' AND fw.time_series_type = 'forecast' -- 予測の行だけに絞る
ORDER BY a.dt;
天気を渡してみる
次は、9 月を隠し、天気(降水量・平均気温)を渡す場合と渡さない場合を比べました。天気を渡す予測は、「共変量を渡す」と同じ SQL で作り、それに天気を渡さない予測と、実績との比較を足しました。渡したのは実際の天気なので、天気予報が完全に当たった場合と同じ条件です。実際に使うなら、渡すのは予報です。
2016 年 9 月の実績と、天気を渡す・渡さない予測(TimesFM 3.0)
天気を渡すと、予測は 1 日平均約 2,400 回動きました。ただ、平均のずれは渡さない場合とほぼ同じで、精度は変わりませんでした。予測が動いても実績に近づかなかった要因は、主に次の 2 つです。
雨の日(降水量 0.2 インチ以上)は 8 日あり、そのうち 7 日で予測が下がりました。しかし、実績が渡さない予測より少なかったのは 2 日だけで、残りは雨でも思ったほど減っていませんでした
月末(9/24〜29)は、天気を渡した予測がさらに下がり、ずれが大きくなりました
天気を渡す・渡さない予測を実績と並べる SQLWITH daily AS ( -- 日ごとの利用回数
SELECT DATE(starttime) AS dt, COUNT(*) AS num_trips
FROM bigquery-public-data.new_york.citibike_trips
WHERE starttime IS NOT NULL
GROUP BY dt
),
weather AS ( -- セントラルパークの観測所の日ごとの天気
SELECT
DATE(CAST(year AS INT64), CAST(mo AS INT64), CAST(da AS INT64)) AS dt,
IF(prcp = 99.99, 0.0, prcp) AS prcp, -- 降水量(インチ)。欠測は 0 とみなす
temp -- 平均気温(華氏)
FROM bigquery-public-data.noaa_gsod.gsod*
WHERE _TABLE_SUFFIX BETWEEN '2013' AND '2016'
AND stn = '725053' AND wban = '94728'
),
daily_with_weather AS ( -- 天気を渡す側の入力(9/30 までの全行を持つ)
SELECT
d.dt,
IF(d.dt < DATE '2016-09-01', d.num_trips, NULL) AS num_trips, -- 9月は NULL にして隠す
w.prcp,
w.temp
FROM daily AS d
JOIN weather AS w ON w.dt = d.dt
),
daily_without_weather AS ( -- 天気を渡さない側の入力(8/31 まで)
SELECT dt, num_trips
FROM daily
WHERE dt < DATE '2016-09-01'
),
fc_with AS (
SELECT *
FROM AI.FORECAST(
TABLE daily_with_weather,
target_cols => ['num_trips'],
timestamp_col => 'dt',
future_covariate_cols => ['prcp', 'temp'], -- 予測する期間の値も分かっている列
model => 'TimesFM 3.0',
horizon => 30,
context_window => 1024
)
),
fc_without AS (
SELECT *
FROM AI.FORECAST(
TABLE daily_without_weather,
target_cols => ['num_trips'],
timestamp_col => 'dt',
model => 'TimesFM 3.0',
horizon => 30,
context_window => 1024
)
)
SELECT
a.dt,
f.prcp,
f.temp,
a.num_trips AS actual,
ROUND(fo.num_trips.value) AS forecast_without_weather,
ROUND(fw.num_trips.value) AS forecast_with_weather,
ROUND(ABS(fo.num_trips.value - a.num_trips) / a.num_trips * 100, 1) AS error_pct_without,
ROUND(ABS(fw.num_trips.value - a.num_trips) / a.num_trips * 100, 1) AS error_pct_with,
ROUND(AVG(ABS(fo.num_trips.value - a.num_trips) / a.num_trips) OVER () * 100, 1) AS avg_error_pct_without,
ROUND(AVG(ABS(fw.num_trips.value - a.num_trips) / a.num_trips) OVER () * 100, 1) AS avg_error_pct_with
FROM daily AS a
JOIN daily_with_weather AS f ON f.dt = a.dt
JOIN fc_without AS fo ON DATE(fo.dt) = a.dt
JOIN fc_with AS fw ON DATE(fw.dt) = a.dt
WHERE fo.time_series_type = 'forecast' AND fw.time_series_type = 'forecast'
ORDER BY a.dt;
結果として、例が数回しかない祝日は予測をあまり動かさず、毎日ある天気は大きく動かしました。ただ、予測が動くことと精度が上がることは別でした。
プロジェクトにどう組み込むか
TimesFM は BigQuery のほかでも動かせます(2026-10-19 時点)。
経路
選べるバージョン
3.0 の扱い
BigQuery(AI.FORECAST)
2.5、3.0
Google Cloud の利用規約に従い、商用で使える。ただし Preview
Model Garden
2.0、2.5(自分でエンドポイントにデプロイする)
3.0 はない
Hugging Face(重みを自前で動かす)
2.5(Apache-2.0)、3.0
3.0 の重みは非商用・非本番に限られる
ライセンス上、3.0 を商用で使えるのは BigQuery だけです(GitHub の README、BigQuery の TimesFM のページ)。ただし BigQuery の 3.0 も Preview で、原則テスト環境向けなので、本番に入れるなら GA 待ちです。1 つの系列の予測で足りるなら、Preview ではない 2.5 を今から使えます。
どちらのバージョンでも、アプリからは、SQL で予測した結果を表に書き込み、それを読む形になります。
おわりに
今回試していないことを残しておきます。
実際の天気ではなく天気予報を渡した場合
別の月でも答え合わせをすると、2.5 と 3.0 の差はどうなるか
多変量で同時に予測すると、別々に予測するより精度が上がるか
共変量と多変量は、今回の執筆で初めて知った言葉でした。実際に試しながら意味を確かめられたので、勉強になりました。共変量は、過去に何度も出てくる情報ほど、予測を動かしやすいのかもしれません。
参考
TimesFM-3: A zero-shot foundation model for multivariate forecasting
The AI.FORECAST function
The TimesFM model
google-research/timesfm
Google Cloud のプロダクトのリリース段階
採用
「あらゆる判断を、Data-Informedに。」
株式会社ギックスでは、一緒に挑戦してくれる仲間を募集しています!
株式会社ギックス HP
株式会社ギックス 採用サイト
エンジニア求人情報
Mentions
@mattpocockuk on the similar lanes this is quite effective google-research/harness2 , been using this alot these days
Harness : quand l'agent améliore lui-même le harness qui l'entoure Quand un agent déçoit, le premier réflexe est de changer de modèle. Pourtant, un même modèle peut passer de décevant à convaincant selon son prompt système, les outils qu’on lui décrit ou sa façon de relire son travail avant de rendre un livrable. Cet environnement a un nom : le harness. Google Research vient de publier en open source Harness², un framework qui le fait évoluer automatiquement, sans toucher aux poids du modèle. Le papier et le site du projet détaillent la méthode, les chiffres et les limites. > TL;DR > > - Harness² optimise le harness d’un agent (prompt, mémoire, outils, sous-agents…) au lieu du modèle. > - Deux boucles : une par tâche (Propose, Probe, Compose), une qui apprend de tâche en tâche. > - Selon Google, une seule étape suffit à améliorer GLM-5 et Gemini 3.5 Flash sur trois benchmarks. Le coût en calcul et la vérification sans réponse de référence restent les points à surveiller. Nous verrons ce qu’est un harness, comment fonctionnent les deux niveaux de récursion, puis nous lancerons la démo locale avant de regarder les limites. Le harness : tout ce qui entoure le modèle Un modèle seul ne fait que produire du texte. Ce qui en fait un agent, c’est le système autour : prompts, outils, mémoire, orchestration. Harness² en expose huit composants modifiables : - Construction du contexte : prompt système, garde-fous, mémoire, skills - Interface d’outils : consignes d’utilisation des outils, scripts - Orchestration : sous-agents - Middleware : plugins !Les huit composants du harness regroupés par rôle à l’exécution : contexte, outils, délégation et middleware L’optimisation de ce système s’appelle le _harnessing_. Les équipes qui maintiennent un AGENTS.md ou des skills à la main font déjà cette boucle : observer un échec, réécrire une consigne, relancer. Harness² la confie à des agents. Premier niveau : Propose, Probe, Compose Pour la tâche en cours, le framework enchaîne trois étapes : 1. Propose : un éditeur analyse une première exécution et propose des modifications du harness. Il en existe deux familles : des règles générales de comportement (par exemple relire un fichier après l’avoir écrit) et des conseils propres au domaine de la tâche. 2. Probe : chaque variante est exécutée sur la même tâche. 3. Compose : un composeur compare les trajectoires obtenues (appels d’outils, observations, fichiers produits) et assemble un harness final à partir des changements dont l’effet est observable. Aucune réponse de référence n’est utilisée : le composeur s’appuie uniquement sur la comparaison des comportements entre exécutions. !Pipeline de Harness² : l’exécuteur lance le harness initial, l’éditeur propose des modifications, les sondes les testent et le composeur produit le harness final Second niveau : apprendre à améliorer Sans mémoire, chaque tâche repartirait de zéro. Après chaque tâche, Harness² conserve donc deux choses dans un _improvement harness_ persistant : le harness qui a été adopté, et les leçons que les traces d’exécution permettent d’appuyer (par exemple, relire le fichier final avant de le rendre). Ces leçons guident les propositions faites sur les tâches suivantes. En revanche, une nouvelle tâche ne reprend pas le harness spécialisé de la précédente : elle repart du harness de base. Un réglage pensé pour analyser un contrat pourrait gêner sur une tâche de tableur, donc le framework garde ce qu’il a appris, pas la configuration elle-même. Parallèle ou séquentiel Le budget de récursion se règle avec --k, de deux façons : | Mode | Comportement | | --- | --- | | --mode parallel | Teste K candidats généraux et K candidats de domaine, puis produit K compositions | | --mode sequential | Enchaîne K étapes Propose, Probe, Compose, en reportant le harness affiné et les preuves | Le mode parallèle explore plus large, le séquentiel affine plus en profondeur. Dans les deux modes, dès que K dépasse 1, un sélecteur compare à l’aveugle les livrables obtenus et n’en garde qu’un, sans savoir de quel harness il vient. !Les deux modes de récursion : parallèle avec k candidats testés simultanément, séquentiel avec k étapes successives Essayer en local La démo ne demande aucun identifiant de modèle : elle lance les deux modes avec des éditeurs déterministes et de vraies exécutions Python. Le dépôt indique Linux et Python 3.12 comme prérequis, mais la démo a aussi tourné sur macOS lors de nos essais. # Environnement Python 3.12 avec uv uv venv --python 3.12 .venv source .venv/bin/activate # Installation du projet depuis la racine du dépôt cloné uv pip install -e '.[render]' # Démo locale, budget de récursion k=2 harness2-demo --output runs/demo --k 2 Pour les benchmarks, Harness² ne remplace pas votre agent : il le pilote. Cet agent s’appelle l’exécuteur. C’est OpenCode par défaut, ou Codex (l’agent de code d’OpenAI) avec l’option --substrate cx. Il faut aussi accéder à des modèles via Vertex AI, et installer Node.js 20+ et gcloud. L’exemple du README lance une tâche du benchmark LAB, puis l’évalue et produit le rapport : harness2-setup lab harness2-check --bench lab harness2 --bench lab --k 1 --mode parallel --only-domains antitrust-competition --max-tasks 1 harness2-judge --bench lab --k 1 --mode parallel harness2-report --bench lab --k 1 --mode parallel Ces runs consomment des appels API : le README recommande de commencer par une petite sélection. Attention, --max-tasks plafonne le nombre de tâches par domaine, pas sur l’ensemble du run. Résultats annoncés et limites Google a évalué Harness² sur trois benchmarks : JobBench (travail professionnel), LAB (tâches juridiques) et WorkBuddy-Bench (bureautique, web et code). Selon ses auteurs, une seule étape améliore GLM-5 et Gemini 3.5 Flash par rapport à leur harness de base : jusqu’à +11,5 points sur WorkBuddy-Bench, +4,5 sur JobBench et +11,8 points de pourcentage sur le taux de réussite partielle de LAB. Un budget de récursion plus élevé (un --k plus grand) apporte des gains supplémentaires. Deux détails pèsent dans la lecture de ces résultats, détaillés dans le papier. L’éditeur et le sélecteur sont Claude Opus 4.8, et le juge de JobBench aussi : un biais est possible. Les échantillons sont petits : 34 tâches de test pour JobBench et 100 pour LAB, avec des scores moyennés sur 3 runs. Le calcul d’abord : chaque proposition, chaque sonde et chaque composition est une exécution de plus de l’agent, donc des tokens et du temps. Plus --k est élevé, plus la facture grimpe. Le choix du meilleur candidat ensuite. Aucune bonne réponse n’est connue pendant l’exécution : le composeur s’appuie sur le comportement observé et, dès que --k dépasse 1, le sélecteur final choisit sans connaître la bonne réponse. Il peut se tromper. Les auteurs relèvent eux-mêmes des régressions : sur une tâche de code de WorkBuddy-Bench, le sélecteur a retenu une sortie notée 55 alors qu’une autre atteignait 95. La sécurité enfin. Garde-fous, scripts et plugins font partie du harness, donc l’éditeur automatique peut les modifier. Côté exécution, l’agent a un accès réseau sans restriction, et l’exécuteur Codex tourne sans sandbox. Le README demande de restreindre le réseau avant toute évaluation, car les critères de notation des benchmarks peuvent se trouver en ligne. Reste le statut du projet : le README précise que ce n’est pas un produit officiellement supporté par Google. À traiter comme un projet à expérimenter avant de l’envisager en production. Et ensuite ? Harness² propose de traiter le harness comme un ensemble de fichiers que l’on peut lire et mesurer, plutôt que comme un réglage fait une fois à la main. Son point faible est la vérification : sans bonne réponse connue, rien ne garantit qu’un harness « amélioré » le soit vraiment. Pour tester sans risque, une équipe peut garder un harness de base figé, laisser les révisions se faire dans un environnement isolé, puis comparer la qualité obtenue au calcul dépensé. 🎓 Aller plus loin avec les agents Vous voulez construire votre propre agent IA ? En une journée, la formation Développer son propre agent de TechTown couvre : - la différence entre un agent et un chatbot ; - l’architecture d’un agent : sessions, mémoire, grounding et MCP ; - les tests, les guardrails et le déploiement ; - trois ateliers pour créer un agent avec ADK, lui ajouter des tools et le brancher à un serveur MCP.
Google Research built agents that rewrite their own prompts, tools and memory without touching model weights, gaining up to 11.8 points on pro benchmarks. 🔗 google-research/harness2 Credits: google-research/harness2 #harness2 #python #aiagents
[Agent Platform in Action (7)] When ADK Go Graph Workflow Meets Google Research Harness (Part 1): Two-Layer Recursive Agent Fram !Harness² Part 1 Architecture: Current Architecture vs c1..c8 Editable Harness Tree Foreword: After the AI Agent Goes Online, What is the Next Bottleneck? Welcome back to the "Agent Platform in Action" series! In the previous six articles, we took the LINE AI Card Assistant (kkdai/line-card-assistant) through the complete five stages from a standalone prototype to production-grade cloud deployment: 1. [[Agent Platform in Action (1)] Building LINE AI Card Assistant with Go and Google ADK 2.0 Graph Workflow](/2026/10/16/adk-graph-workflow-go/): Using google.golang.org/adk/v2/workflow to build a 13-node directed graph featuring Vision OCR, AddFanOut / AddFanIn parallel background investigation, and NewRequestInputEvent HITL interaction loops. 2. [[Agent Platform in Action (2)] From Monolithic Cloud Run to Google Cloud Agent Runtime](/2026/10/17/adk-graph-workflow-go-2/): Completed Phase 1 (PR #10), standardizing the project into an agents-cli (adk_go) dual-channel architecture. 3. [[Agent Platform in Action (3)] Implementing Cross-Instance Business Card Recognition and Pause/Resume with ADK 2.0 GCS Artifact Service and Agent Platform Sessions](/2026/10/20/adk-graph-workflow-go-3/): Completed the first half of Phase 2 (PR #11), solving the HITL "amnesia" of stateless containers and the issue of binary image data explosion. 4. [[Agent Platform in Action (4)] Completing the Architecture Puzzle with Cloud Run LINE Webhook Gateway, Agent Runtime, and Cloud SQL PostgreSQL](/2026/10/21/adk-graph-workflow-go-4/): Completed the second half of Phase 2 & Phase 3 (PR #12), upgrading the business card database to Cloud SQL PostgreSQL and achieving two-layer stateless decoupling. 5. [[Agent Platform in Action (5)] Establishing Automated Evaluation Benchmarks with agents-cli eval and ADK 2.0 Go](/2026/10/22/adk-graph-workflow-go-5/): Completed Phase 4 (PR #13), establishing a golden test set and a quality flywheel. 6. [[Agent Platform in Action (6)] Building a Full-Stack Observable LINE AI Card Assistant with agents-cli deploy, Terraform IaC, GitHub Actions CI/CD, and Cloud Trace](/2026/10/24/adk-graph-workflow-go-6/): Completed Phase 5 (PR #14), adding OpenTelemetry Spans (workflow.node.*) to all 13 Graph Nodes and deploying to the cloud via Terraform and GitHub Actions. Additionally, we have planned advanced Graph Workflow primitives for future ADK v2.6+ (such as Parallel dynamic dispatch, MultiRoute multi-way fan-out, reflection-repair loops, and strongly typed nodes) into the Future Roadmap Issue #15. However, now that our Agent possesses Phase 4 Automated Evaluation and Phase 5 Full-Node Tracing (OpenTelemetry Spans), a deeper question emerges: > When evaluations find that the Agent performs poorly in certain real-world cases (e.g., Japanese-English bilingual cards, searching for colloquial nicknames in the Taiwan tech industry, multi-clause colloquial HITL modifications, or financial compliance icebreaker letters), must engineers manually check logs, manually modify Prompt strings in the Go source code, and then recompile and redeploy every single time? Can we let a Meta-Agent (Engineer Agent) automatically read our evaluation results and OpenTelemetry execution trajectories, diagnose the reasons for lost points, write new Skills and Long-Term Memory itself, and complete a closed-loop self-evolution? This is precisely the core proposition addressed by Google Research's recently published and open-sourced google-research/harness2 (_Harness²: Recursive Harnessing for LLM Agents_, arXiv:2610.14224)! This series will consist of three articles (corresponding to Issue #16, Issue #17, Issue #18, and PR #19), guiding you through the complete integration of Google Research Harness² into our Go ADK 2.0 project: - Part 1 (This article, Issue #16): What is Google Harness²? What can it do? Inventorying the existing architecture before modification and decoupling hardcoded configurations in the Go binary into a c1..c8 editable Harness file tree. - Part 2 (Issue #17): Encapsulating Phase 4's eval/datasets/ and Phase 5's OpenTelemetry Spans into a LineCardAdapter for Harness², and analyzing why "Zero-Leakage Barriers" and "High-Resolution Behavioral Trajectories" are both indispensable. - Part 3 (Issue #18): Complete testing tutorial, disclosure of Parallel vs Sequential + LongTermMEMORY two-layer recursive experimental data (accuracy jumping from 62.5% to 100.0%), and outlook on the future development of Agents. - * * 1. What is Google Research Harness²? What can it do? 1. From "Fine-tuning Model Weights" to "Optimizing Agent Harnessing" In the era of Large Language Models (LLMs), the success of an AI Agent is often determined not just by the underlying model (like Gemini 2.5 Flash / Pro) itself, but by the "Agent Harness" layer wrapped around the model—namely, System Prompts, formatting and safety Guardrails, Long-Term Memory, Tool Descriptions, Sub-Agent settings, and On-Demand Skills. Past automated prompt optimizations (like PromptBreeder, TextGrad, or DSPy) mostly targeted mutations of a single Prompt string. However, in a real enterprise-grade Agent, if you stuff all edge-case patching rules into the same System Prompt, it quickly leads to "Context Bloat": the number of Always-On Tokens increases, which not only squeezes the model's Output Token Budget but also causes different rules to interfere with each other! Google Research's Harness² (_Recursive Harnessing for LLM Agents_, arXiv:2610.14224) breaks this limitation. It defines the entire Agent's equipment as a structured c1..c8 Editable Component Tree and automatically evolves this file tree through Two-Level Recursion: flowchart TB subgraph Outer["Outer Recursion: Harnessing-Level Recursion (Experience accumulation across rounds k=1..K)"] Reflector["Experience Reflector\n(ReflectorPrompt)\nDistilling success and failure patterns across rounds"] Memory["c3: LongTermMEMORY.md\nPersisting domain experience across iterations"] Reflector -->|Write long-term rules| Memory end subgraph Inner["Inner Recursion: Task-Level Recursion (Closed-loop diagnosis and patching within a single round)"] Propose["1. Propose (Diagnostic Proposal)\nRead trajectory.md\nLocate behavioral defects and propose N candidate Patches"] Probe["2. Probe (Sandbox Probing)\nExecute N candidate Harnesses in parallel\nCompare trajectory and score changes before and after modification"] Compose["3. Compose (Conflict Resolution and Synthesis)\nRetain positive-gain Patches, discard negative interference\nSynthesize the next-generation best Harness H^(k)"] Propose --> Probe --> Compose end Memory --> Propose Compose --> Reflector 2. The c1..c8 Eight Harness Components and the 24KB Budget Limit In google-research/harness2/harness.py, a standard Agent Harness consists of 4 Always-On Components (c1..c4) and 4 On-Demand Components (c5..c8): | Component Code | File Path Rule | Loading Mode | Responsibility in Harness² | | --- | --- | --- | --- | | c1 | systemprompt.md | Always-On | Defines core roles, node responsibilities, and indexing rules for triggering c6 skills (Skill Trigger Lines). | | c2 | guardrails.md | Always-On | Defines non-violable output formats (e.g., pure JSON constraints), E.164 normalization invariants, and security/PII guardrails. | | c3 | LongTermMEMORY.md | Always-On | Domain lessons and industry compliance memories written across rounds by the outer Experience Reflector. | | c4 | tool_descriptions/*.md | Always-On | Tool parameter descriptions, search strategies, and normalization details (e.g., Japan long-distance prefix (0) cleaning rules). | | c5 | sub_agents/<name>/AGENT.md | On-Demand | Exclusive Instructions and YAML Frontmatter (mode: subagent) for delegated sub-agents (e.g., company_enrich). | | c6 | skills/<name>/SKILL.md | On-Demand | Professional skills dynamically injected only for specific domains or tasks (e.g., Japanese card OCR, Hsinchu Science Park colloquial search expansion). | | c7 | scripts/*.py | On-Demand | Auxiliary verification or data conversion scripts. | | c8 | plugin/*.ts | On-Demand | Runtime lifecycle and OpenTelemetry diagnostic hooks. | > [!IMPORTANT] Why distinguish between c1..c4 (Always-On) and c5..c8 (On-Demand)? Harness² sets an iron rule in OPENCODE_SURFACE: The total byte count of c1..c4 always-on files must never exceed 24,000 bytes (AlwaysOnMaxBytes = 24000)! When Harness² finds that a failure case requires lengthy domain knowledge (e.g., a department title mapping table for Japanese corporate cards or a colloquial abbreviation dictionary for Taiwan semiconductor parks), it won't force these rules into systemprompt.md causing Context explosion. Instead, it will automatically create an on-demand c6 skill file (skills/<name>/SKILL.md) and leave only a lightweight Trigger Line in systemprompt.md! 3. How Does Two-Level Recursion Work? The Harness² evolution engine (harness2/core/recursion.py) includes two levels of recursion: 1. Level 1 (Inner): Task-Level Recursion (Propose $\rightarrow$ Probe $\rightarrow$ Compose): - Propose (Diagnostic Proposal): The optimizer (Harvester LLM) observes tasks that lost points in the benchmark, the final output (Deliverable), and the execution trajectory (trajectory.md). It diagnoses which step the Agent made a wrong decision on and proposes multiple independent candidate patches (Candidate Patches, each being a set of additions or modifications to c1..c8). - Probe (Sandbox Probing): The system mounts each candidate Patch into a sandbox and runs the task once, collecting new scores and new execution trajectories to form a "Before vs. After" Behavioral Contrast. - Compose (Conflict Resolution and Synthesis): The optimizer compares the actual performance of all candidate Patches, merges modifications that truly bring positive score improvements into the new generation Harness $H^{(k)}$, and discards ineffective or side-effect-inducing modifications. 2. Level 2 (Outer): Harnessing-Level Recursion (Experience Reflector): - Between multiple iterations ($k = 1 \dots K$), the outer ReflectorPrompt reviews the evolution history from $H^{(0)} \to H^{(1)} \to \dots \to H^{(k)}$: Which types of modifications succeeded? Which cross-domain constraints (e.g., financial industry compliance headers and personal data protection regulations) need to be remembered at a global level? - Distilled high-level experiences are written into c3: LongTermMEMORY.md, guiding the next round of Propose and Agent execution. - * * 2. Inventorying the Current Architecture: Why Phase 1~5 Go Implementation Cannot Be Directly Optimized by Harness²? Let's compare the left side (Before: Hardcoded Go Binary Architecture) of the architecture diagram at the beginning of the article and review the system architecture we completed in Phase 1 to Phase 5: flowchart LR subgraph Before["Before: Phase 1~5 Solid Compilation Architecture (Hardcoded Go Binary)"] direction TB N1["nodes_ingest.go\nHardcoded VisionOCR / CleanAndDedup /\nCompanyEnrich / ApplyEdit Prompts"] N2["nodes_search.go\nHardcoded QueryIntent /\nEmailDraft Prompts"] N3["graph.go\nFixed SubAgent Instruction"] E["eval/datasets/basic-dataset.json\nPhase 4 Golden Test Set"] T["telemetry.go\nPhase 5 OpenTelemetry 13-Node Spans"] end Problem["Pain Points:\n1. Prompts hardcoded in .go string constants; re-go build required for every adjustment\n2. Lack of on-demand c6 Skills mechanism\n3. Phase 4 Eval and Phase 5 OTel Spans not yet connected into an automated optimization loop"] Before --> Problem In the code before modification: 1. All Prompt templates for VisionOCRNode, CompanyEnrichNode, ApplyEditNode (located in internal/workflow/nodes_ingest.go), as well as QueryIntentNode and EmailDraftNode (located in internal/workflow/nodes_search.go), are hardcoded as fmt.Sprintf(...) string constants in the Go code. 2. The llmagent.Config{Instruction: ...} for CompanyEnrichAgent (located in internal/workflow/nodes_ingest.go) is also a fixed string hardcoded in a Go function. 3. When we want the Harness² Python evolution loop to modify system prompts, add a Japanese business card recognition skill (skills/jp_card_ocr/SKILL.md), or adjust phone number normalization guidelines (tool_descriptions/tool_guidance.md), it is impossible for Harness² to rewrite our .go source code and go build again for every single task! Therefore, the first step in introducing Harness² (Issue #16) is to completely decouple the Go compilation phase from the Harness equipment phase: - Go Binary is responsible for "Graph Topology and Dual-Channel Execution Engine": RouterNode, parallel Fan-Out/Fan-In, HITLConfirmNode pause/resume, Cloud SQL and GCS Artifact access, OpenTelemetry instrumentation. - External c1..c8 Harness file tree is responsible for the "Evolvable Brain and Skill Equipment": Dynamically hot-reloaded at runtime via the environment variable LINE_CARD_HARNESS_DIR! - * * 3. Implementation Part 1: Building internal/harness Loader and harness/base/ Initial Equipment Tree To make the Go ADK 2.0 Graph Workflow natively understand the Harness² c1..c8 specification, we added two core directories to the project: - harness/base/: Stores the initial unevolved ($k=0$) c1..c8 baseline file tree. - internal/harness/harness.go: Responsible for loading the c1..c8 file tree, validating OPENCODE_SURFACE 24KB limits and YAML Frontmatter rules, and dynamically combining always-on context with on-demand skills for each Graph Node. 1. Establishing the harness/base/ Baseline c1..c8 File Tree We extracted the basic settings previously scattered in nodes_ingest.go and nodes_search.go into the harness/base/ directory: harness/base/ ├── systemprompt.md # c1: Core roles and overview of Graph Node responsibilities ├── guardrails.md # c2: JSON output format and phone normalization guardrails ├── LongTermMEMORY.md # c3: Cross-round long-term memory (initially empty, to be written by Reflector) ├── tool_descriptions/ │ └── tool_guidance.md # c4: Guidance for google_search and format_company_profile ├── sub_agents/ │ └── company_enrich/ │ └── AGENT.md # c5: CompanyEnrichAgent delegated sub-agent definition ├── skills/ # c6: On-demand skills directory (to be generated by Propose-Probe-Compose evolution) ├── scripts/ # c7: Auxiliary scripts directory └── plugin/ # c8: Diagnostic plugin directory For example, harness/base/sub_agents/company_enrich/AGENT.md uses a YAML Frontmatter format fully compatible with google-research/harness2: --- mode: subagent description: Searches Google to summarize company background and industry tags. --- You are a professional business analysis assistant. Please use the google_search tool to query the company name provided by the user, then call the format_company_profile tool to return a two-sentence Traditional Chinese company profile and 3 to 5 technical or industry tags (e.g., AI, Cloud, SaaS, FinTech, Semiconductor). 2. Implementing internal/harness/harness.go: 24KB Always-On Budget Validation and On-Demand Skill Injection In internal/harness/harness.go, we implemented the validator Validate() and node prompt synthesizer ComposeNodePrompt() fully aligned with the Python version of OPENCODE_SURFACE.validate(): // AlwaysOnMaxBytes matches google-research/harness2 OPENCODE_SURFACE.always_on_max (24,000 bytes). const AlwaysOnMaxBytes = 24000 // AlwaysOnFiles lists the four c1..c4 always-on files mounted into the agent context. var AlwaysOnFiles = []string{ "systemprompt.md", "guardrails.md", "LongTermMEMORY.md", "tool_descriptions/tool_guidance.md", } // Validate enforces Google Research Harness² OPENCODE_SURFACE invariants on the c1..c8 tree. func (h *Harness) Validate() error { var problems []string for _, reqFile := range AlwaysOnFiles { if strings.TrimSpace(h.Files[reqFile]) == "" { problems = append(problems, fmt.Sprintf("missing or empty always-on component %q", reqFile)) } } if total := h.AlwaysOnBytes(); total > AlwaysOnMaxBytes { problems = append(problems, fmt.Sprintf( "always-on c1..c4 byte count %d exceeds cap %d (every token here competes with the agent's output budget)", total, AlwaysOnMaxBytes, )) } combinedAlwaysOn := h.SystemPrompt + "\n" + h.Guardrails for dirName, skill := range h.Skills { if skill.Name != dirName { problems = append(problems, fmt.Sprintf("%s frontmatter name %q must match directory name %q", skill.RelPath, skill.Name, dirName)) } if strings.TrimSpace(skill.Description) == "" { problems = append(problems, fmt.Sprintf("%s must include a non-empty description in YAML frontmatter", skill.RelPath)) } if !strings.Contains(combinedAlwaysOn, dirName) { problems = append(problems, fmt.Sprintf("skill %q is never referenced in systemprompt.md or guardrails.md; add a trigger line naming `%s`", dirName, dirName)) } } // ... } There are two very critical engineering details here: 1. Mandatory Skill Trigger Reference Check: If Harness² adds a skill in skills/jp_card_ocr/SKILL.md but forgets to write when to trigger jp_card_ocr in systemprompt.md or guardrails.md, Validate() will directly report an error and intercept it, avoiding the creation of "Orphan Skills" that will never be called. 2. Node-Level Dynamic Skill Matching (MatchSkills + ComposeNodePrompt): When the Graph Workflow executes VisionOCRNode, CompanyEnrichNode, ApplyEditNode, QueryIntentNode, or EmailDraftNode, it only injects c6 skills related to that node and automatically attaches adk.harness.skills and adk.harness.always_on_bytes attributes to the OpenTelemetry Span: // composeHarnessPrompt enriches a node's base prompt with the active Harness² c1..c8 tree // and records the active harness metadata onto the node's OpenTelemetry span. func composeHarnessPrompt(span trace.Span, nodeName, basePrompt, taskInput string) (string, *harness.Harness) { h := harness.LoadActive() composed, skills := h.ComposeNodePrompt(nodeName, basePrompt, taskInput) if span != nil { span.SetAttributes( AttrHarnessRoot.String(h.RootDir), AttrHarnessAlwaysOnBytes.Int(h.AlwaysOnBytes()), ) if len(skills) > 0 { span.SetAttributes(AttrHarnessSkills.StringSlice(skills)) } } return composed, h } We can directly use the CLI to verify the always-on byte count and structural validity of harness/base: $ go run ./cmd/harness-runner --validate-only --harness-dir=harness/base OK (1599 always-on bytes) - * * 4. Summary and Next Article Preview In this article (Part 1), we completed the goals of Issue #16: 1. Gained a deep understanding of how Google Research Harness² (arXiv:2610.14224) automatically evolves Agent capabilities through a c1..c8 editable file tree and two-level recursion (Task-level Propose-Probe-Compose + Harnessing-level Experience Reflector), without recompiling the main program or bloating the always-on context. 2. Inventoried the architectural bottlenecks of Phase 1~5 where prompts and normalization logic were hardcoded in the Go binary. 3. Implemented internal/harness/harness.go and harness/base/, allowing the 13 nodes of the ADK 2.0 Go Graph Workflow to support dynamic mounting of the c1..c8 equipment tree via LINE_CARD_HARNESS_DIR. With a hot-reloadable c1..c8 equipment tree in place, the next article [[Agent Platform in Action (8)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 2): Turning Phase 4 Eval Datasets and Phase 5 OpenTelemetry Spans into LineCardAdapter](/2026/10/26/adk-graph-workflow-go-8/) will enter Issue #17: revealing why we need to encapsulate Phase 4 test sets and Phase 5 OpenTelemetry Spans into a LineCardAdapter, and how the "Zero-Leakage Barrier" prevents the AI optimizer from cheating!
[Agent Platform in Action (8)] When ADK Go Graph Workflow Meets Google Research Harness (Part 2): Integrating Phase 4 Eval Datas !Harness² Part 2: LineCardAdapter Bridging Phase 4 Eval & Phase 5 OpenTelemetry Spans Introduction: Why Decoupling Prompts Isn't Enough? In the previous post [[Agent Platform in Practice (7)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 1): Understanding the Recursive Agent Framework and c1..c8 Decoupling](/2026/10/25/adk-graph-workflow-go-7/) (corresponding to Issue #16), we introduced Google Research's google-research/harness2 (arXiv:2610.14224) and decoupled the prompts, guardrails, long-term memory, and on-demand skills of the LINE AI Business Card Assistant (kkdai/line-card-assistant) into an editable c1..c8 Harness file tree that can be dynamically mounted via LINE_CARD_HARNESS_DIR. However, simply having a hot-reloadable c1..c8 file tree isn't enough; the Harness² dual-layer recursive optimization engine (harness2/core/recursion.py) still cannot directly optimize our Go Agent. This is because Harness² requires a standardized bridge — the BenchmarkAdapter — to answer three questions: 1. list_tasks() (What are the test questions?): What test cases should the Agent challenge? What are the public inputs (Prompt / Image) for each case, and what are the hidden scoring criteria (Rubrics)? 2. run() (How does the Agent think and execute?): When a candidate Harness $H’$ is mounted to execute a specific task, what is the Agent's final output (deliverable)? More importantly, what decision trajectory did it follow across the 13 nodes of the Graph Workflow (trajectory.md)? 3. score() (How was the performance?): Which independent scoring items (per_rubric) did the final output and execution trajectory pass, and where did it lose points? Conveniently, in Phase 4 (PR #13), we already established the eval/datasets/ automated evaluation benchmark, and in Phase 5 (PR #14), we added workflow.node.* OpenTelemetry Spans to all 13 Graph Nodes! In this post (Part 2, corresponding to Issue #17 and PR #19), we will merge these two existing assets into the Harness² LineCardAdapter and explain in depth "Why must it be designed this way?". --- I. Why This Change? Two Core Design Philosophies Before diving into the code, many engineers often ask two questions: 1. _"Why not just feed the entire eval/datasets/*.json file to the optimizer LLM and let it modify the Prompt itself?"_ 2. _"Why do we need to convert Phase 5 OpenTelemetry Spans into trajectory.md? Isn't the final LINE reply text and total score enough?"_ These two questions touch upon the most core engineering designs in the Harness² paper: the Zero-Leakage Barrier and Behavioral Trajectory Contrast. 1. Why the "Zero-Leakage Barrier"? Preventing the Optimizer from "Cheating by Memorizing Answers" Imagine this: if we expose the golden answers in eval/datasets/harness2-challenge-dataset.json (e.g., expected_contains: ["+81-3-1234-5678", "Kenta Sato"]) directly to the Agent or the Harness² Harvester LLM, what happens? The laziest and easiest way for the optimizer to get a perfect score is to write a single line in systemprompt.md: > _"If the input contains Sato, please directly output +81-3-1234-5678 and Kenta Sato."_ In machine learning, this is known as Test Set Leakage / Overfitting to Rubrics. The Agent appears to score 100 on the benchmark, but it crashes immediately when it encounters a new Japanese business card in the real world! flowchart LR Dataset["eval/datasets/*.json\nComplete Test Cases\n(incl. expected_contains / expected_route)"] subgraph Barrier["Zero-Leakage Barrier"] Brief["TaskBrief (Public Task Sheet)\nContains only task_id, prompt, session_input\n❌ Never contains expected_* golden answers"] Scorer["Isolated Scorer (Black-box Grader)\nHolds independent hidden r1..r4 Rubrics"] end Agent["Go ADK Graph Workflow\n(cmd/harness-runner)"] Harvester["Harness² Harvester\n(Propose / Probe / Compose)"] Dataset --> Brief Dataset --> Scorer Brief -->|"Public Input"| Agent Agent -->|"Deliverable + OTel trajectory.md"| Scorer Scorer -->|"Returns only binary scores {r1: 1.0, r2: 0.0}\nand OTel execution trajectory"| Harvester In the Harness² BenchmarkAdapter architecture: - TaskBrief (Public Task Sheet): Can only contain user input (prompt and simulated images/context). It is strictly forbidden from containing any expected_contains, expected_not_contains, or correct answer strings. - score() (Black-box Grader): After comparing answers in an isolated environment, it only returns "which Rubric received a 1.0 or 0.0" (e.g., r3_e164_jp_phone_normalization: 0.0), without leaking the expected string to the optimizer. - As a result, the Harness² optimizer must truly reason out general domain rules (e.g., "When converting Japanese phone numbers +81 (0)3-... to E.164, the domestic trunk prefix (0) must be removed") by reading the Agent's execution trajectory (trajectory.md), rather than hard-coding answers into the Prompt! 2. Why Convert Phase 5 OpenTelemetry Spans into trajectory.md? Opening the Black Box of the 13-Node Graph Our LINE AI Business Card Assistant is not a simple "single LLM call," but an ADK 2.0 Directed Acyclic Graph (Graph Workflow) consisting of 13 nodes: - Business Card Ingestion Pipeline: RouterNode $\rightarrow$ VisionOCRNode $\rightarrow$ Parallel AddFanOut (CleanAndDedupNode, CompanyEnrichNode, ContextMatchNode) $\rightarrow$ AddFanIn (JoinPreviewNode) $\rightarrow$ BuildPreviewNode $\rightarrow$ HITLConfirmNode $\rightarrow$ ApplyEditNode / SaveCardNode. - Smart Search and Icebreaker Pipeline: RouterNode $\rightarrow$ QueryIntentNode $\rightarrow$ HybridSearchNode / BroadSearchNode $\rightarrow$ FormatCarouselNode / EmailDraftNode. Without Phase 5's OpenTelemetry Spans, when a user searches for _"Help me find a Sales Director in Hsinchu Science Park working on AI chips"_ but gets _"No matching business cards found"_ (score 0.25), the optimizer only sees this final failure message and cannot determine: - Did the RouterNode misjudge the search intent as chat? - Did the QueryIntentNode fail to expand the colloquial "Hsinchu Science Park" into the database's address Hsinchu, or fail to expand "Sales Director" into Sales Director? - Was there an error in the SQL query logic of the HybridSearchNode? When we collect and render Phase 5's OpenTelemetry workflow.node.* Spans into a structured trajectory.md, Harness² can clearly see the input, output, duration, routing decisions, and loaded skills of every Graph Node during the Propose and Probe stages! --- II. Implementation Part 2: Building the LineCardAdapter in Three Steps Referring to the architecture diagram at the beginning of this article, we completed the implementation of Issue #17 through three core components: 1. internal/harness/trajectory.go: Mounts an in-memory OpenTelemetry SpanRecorder on the Go side to export the execution details of the 13 Graph Nodes as trajectory.md. 2. cmd/harness-runner/main.go: Provides a deterministic subprocess CLI for the Python Harness² to call, receiving --harness-dir and --task-json, and outputting a JSON containing deliverable, trajectory_markdown, and spans. 3. harness/line_card_adapter.py: Implements the google-research/harness2 BenchmarkAdapter interface, connecting the Phase 4 Dataset with the Go harness-runner. 1. Implementing internal/harness/trajectory.go: Rendering OpenTelemetry Spans as trajectory.md In Phase 5, our internal/workflow/telemetry.go already calls startNodeSpan(ctx, "VisionOCRNode") in every Graph Node. Now, in internal/harness/trajectory.go, we use go.opentelemetry.io/otel/sdk/trace/tracetest.NewSpanRecorder() to intercept all completed node Spans and transform them into a Markdown trajectory report that the Harness² Harvester LLM is best at reading: // InstallRecorder registers a new in-memory SpanRecorder as the global OTel TracerProvider // so every workflow.node.* span emitted by internal/workflow/telemetry.go is captured. func InstallRecorder() (*TrajectoryRecorder, func()) { sr := tracetest.NewSpanRecorder() tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) prev := otel.GetTracerProvider() otel.SetTracerProvider(tp) rec := &TrajectoryRecorder{recorder: sr, tp: tp} restore := func() { _ = tp.Shutdown(context.Background()) otel.SetTracerProvider(prev) } return rec, restore } At the same time, we added three OpenTelemetry Attributes to internal/workflow/telemetry.go: AttrNodeDecision (adk.node.decision), AttrNodeDiagnostic (adk.node.diagnostic), and AttrHarnessSkills (adk.harness.skills). When TrajectoryRecorder.RenderMarkdown() executes, it produces a high-resolution trajectory.md like this: # ADK 2.0 Go Graph Workflow Execution Trajectory - **Task ID** : `challenge_colloquial_tw_search` - **Harness Root** : `harness/base` - **Always-On Bytes (`c1..c4`)**: `1599 / 24000` - **Final Score** : `0.25` ## OpenTelemetry Node Spans (`workflow.node.*`) ### 1. `workflow.node.RouterNode` - **Node** : `RouterNode` | **Status** : `OK` | **Duration** : `0.02 ms` - **Decision** : `intent=search (colloquial_query=Help me find a Sales Director in Hsinchu Science Park working on AI chips, I need to contact Taiwan semiconductor clients)` - **Diagnostic** : `routed search query to QueryIntentNode` ### 2. `workflow.node.QueryIntentNode` - **Node** : `QueryIntentNode` | **Status** : `OK` | **Duration** : `0.04 ms` - **Decision** : `parsed_filters={"location":"Hsinchu Science Park","role":"Sales Director","industry":"AI Chips"}` - **Diagnostic** : `UNEXPANDED_COLLOQUIAL_FILTERS: colloquial Taiwan science park alias 'Hsinchu Science Park' and Mandarin title 'Sales Director' were passed literally without bilingual synonym expansion; add a c6 skill 'tw_semicon_search' with BILINGUAL_SYNONYM_MAP` ### 3. `workflow.node.HybridSearchNode` - **Node** : `HybridSearchNode` | **Status** : `OK` | **Duration** : `0.03 ms` - **Decision** : `matched_cards=0 (strict literal filter on location='Hsinchu Science Park' failed against DB address='Hsinchu Science Park (竹科)')` Please pay attention to the Span records of the 2nd node workflow.node.QueryIntentNode and the 3rd node workflow.node.HybridSearchNode! Through this trajectory, the Propose stage of Harness² can see at a glance: RouterNode was correct, the error was in QueryIntentNode treating "Hsinchu Science Park" as a rigid string to query the English address field! 2. Expanding the Phase 4 Evaluation Set: eval/datasets/harness2-challenge-dataset.json To fully test Harness²'s ability to retain existing capabilities (preventing catastrophic forgetting) and conquer difficult edge cases, we established 8 tasks (totaling 32 fine-grained Rubrics) in eval/datasets/harness2-challenge-dataset.json: | Task Category | Task ID | Scenario Description | 4 Independent Scoring Rubrics (r1..r4) | | --- | --- | --- | --- | | Baseline (Regression) | baseline_ingest_ming_wang | Standard Traditional Chinese card ingestion (Wang Xiaoming / Taiwan Cloud Tech) | Intent routing, Name/Title extraction, E.164 phone normalization, Industry tag enrichment | | Baseline (Regression) | baseline_dedup_appier_promotion | Duplicate detection for promotion (Lin Yating Senior $\rightarrow$ VP) | Dedup detection, Title update, Keep new mobile, Company consistency | | Baseline (Regression) | baseline_search_cloud_summit_ai | Standard keyword search (Cloud Summit + AI) | Search routing, Multiple hits, Tag display, Interactive buttons | | Baseline (Regression) | baseline_email_google_cloud | General tech follow-up icebreaker (Chen Jianhong / Google Cloud) | Email routing, Recipient/Event mention, Agenda link, Professional closing | | Challenge (Evolution 1) | challenge_jp_bilingual_ingest | Bilingual JP/EN card OCR & JP phone normalization (Kenta Sato / CTO / +81 (0)3-1234-5678) | r1 Routing, r2 Bilingual name retention, r3 JP +81 remove (0) normalization, r4 JP title bilingual mapping | | Challenge (Evolution 2) | challenge_colloquial_tw_search | Colloquial Taiwan tech alias search ("Help me find a Sales Director in Hsinchu Science Park") | r1 Routing, r2 Hsinchu Science Park $\leftrightarrow$ Hsinchu bilingual expansion hit, r3 Sales Director $\leftrightarrow$ Sales Director title expansion, r4 Tag completeness | | Challenge (Evolution 3) | challenge_noisy_multi_edit_hitl | Multi-clause colloquial HITL continuous correction ("Sorry, let me correct that, the title should be Group Senior VP, also change the end of the mobile to 8899, and add GDG DevFest speaker to notes") | r1 HITL Edit routing, r2 Multi-field atomic update (Title), r3 Mobile suffix partial replacement & Note append, r4 Retain original company/email | | Challenge (Evolution 4) | challenge_pii_safe_executive_followup | Financial executive compliance & PII masking icebreaker (Zhang Yazhu / Cathay Financial, Digital Risk Officer, requires privacy protection and compliance headers) | r1 Email routing, r2 Executive title/scenario, r3 Mobile/Private Email PII Redaction, r4 Financial compliance subject header [Confidential / FinTech Compliance Verified] | 3. Implementing harness/line_card_adapter.py: Connecting to google-research/harness2 Finally, we implemented the LineCardAdapter in harness/line_card_adapter.py, connecting all the above components: class LineCardAdapter: """Adapts `line-card-assistant` to the `google-research/harness2` BenchmarkAdapter protocol.""" def list_tasks(self) -> list[TaskBrief]: """Return public task briefs with zero ground-truth rubric leakage.""" briefs: list[TaskBrief] = [] for raw in self._raw_cases: # Strip `rubrics` and `expected_*` so the agent/harvester never sees ground truth. briefs.append( TaskBrief( task_id=raw["id"], prompt=raw["prompt"], context={ "category": raw.get("category", "general"), "description": raw.get("description", ""), "session_input": raw.get("session_input", {}), }, ) ) return briefs def run(self, harness: Harness, task: TaskBrief) -> RunArtifact: """Mount `harness` in a temporary directory and execute the Go Graph Workflow runner.""" problems = harness.validate() if problems: diag = "# Harness Validation Failed\n" + "\n".join(f"- {p}" for p in problems) return RunArtifact(task_id=task.task_id, deliverable="", trajectory=diag) with tempfile.TemporaryDirectory(prefix=f"line_card_harness_{task.task_id}_") as tmp_dir: harness_root = Path(tmp_dir) harness.materialize(harness_root) # Invoke the compiled Go binary with --harness-dir pointing to the materialized c1..c8 tree proc = subprocess.run( [str(binary), f"--harness-dir={harness_root}", f"--task-json={task_payload_path}"], capture_output=True, text=True, check=False, timeout=30, ) payload = json.loads(proc.stdout) return RunArtifact( task_id=task.task_id, deliverable=payload.get("deliverable", ""), trajectory=payload.get("trajectory_markdown", ""), raw=payload, ) def score(self, task: TaskBrief, artifact: RunArtifact) -> ScoreResult: """Evaluate the deliverable against isolated per-rubric criteria.""" raw = self._cases_by_id[task.task_id] rubrics = raw.get("rubrics", {}) per_rubric: dict[str, float] = {} for rubric_id, spec in rubrics.items(): per_rubric[rubric_id] = 1.0 if self._evaluate_rubric(artifact.deliverable, spec) else 0.0 total = sum(per_rubric.values()) / len(per_rubric) if per_rubric else 0.0 return ScoreResult(task_id=task.task_id, total=round(total, 4), per_rubric=per_rubric) Through this LineCardAdapter: - Whenever Harness² proposes a new candidate Harness $H’$, adapter.run(harness, task) first calls harness.validate() to check the 24KB OPENCODE_SURFACE limit and Skill Frontmatter rules. - Once validated, the c1..c8 files of $H’$ are materialized into a temporary directory and passed to Go's harness-runner for execution. - After the Go 13-node Graph Workflow finishes, it returns the final LINE message (deliverable) and OpenTelemetry trajectory (trajectory_markdown), which are then passed to adapter.score() for leakage-free independent scoring! --- III. Summary and Teaser for the Next Part In this post (Part 2), we completed the core engineering for Issue #17: 1. Clarified why the golden test set cannot be fed directly to the optimizer — through the TaskBrief Zero-Leakage Barrier, we force Harness² to reason out general skills from behavioral trajectories rather than memorizing answers. 2. Through internal/harness/trajectory.go and cmd/harness-runner/main.go, we transformed Phase 5's OpenTelemetry workflow.node.* Spans into high-resolution trajectory.md, opening the black box of the 13-node Graph Workflow. 3. Established eval/datasets/harness2-challenge-dataset.json (8 major tasks, 32 fine-grained Rubrics) and harness/line_card_adapter.py, perfectly implementing the Harness² BenchmarkAdapter protocol. Everything is ready! In the final post [[Agent Platform in Practice (9)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 3): Dual-Layer Recursive Testing Guide, 62.5% to 100% Performance Results, and the Future of AgentOps](/2026/10/27/adk-graph-workflow-go-9/) (corresponding to Issue #18), we will actually launch the dual-layer recursive optimization experiment, reveal the complete quantitative data evolving from 62.5% to 100.0%, and discuss the profound impact of Harness² on future enterprise-grade Agent development!
[Agent Platform in Action (9)] When ADK Go Graph Workflow Meets Google Research Harness (Part 2): Two-layer Recursive Testing Gu !Harness² Part 3: Recursive Optimization Benchmark & Future AgentOps Outlook Introduction: From "Manual Prompt Tuning" to "Two-Layer Recursive Autonomous Evolution" Welcome to the final part of the Agent Platform in Practice Harness² trilogy! In the previous two articles: - [[Agent Platform in Practice (7)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 1): Understanding the Two-Layer Recursive Agent Harnessing Framework and c1..c8 Decoupling Practice](/2026/10/25/adk-graph-workflow-go-7/) (Issue #16): We introduced Google Research's google-research/harness2 (arXiv:2610.14224) and decoupled the 13-node Graph Workflow of LINE AI Card Master (kkdai/line-card-assistant) into an editable c1..c8 file tree supporting dynamic mounting via LINE_CARD_HARNESS_DIR. - [[Agent Platform in Practice (8)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 2): Turning Phase 4 Eval Datasets and Phase 5 OpenTelemetry Spans into LineCardAdapter](/2026/10/26/adk-graph-workflow-go-8/) (Issue #17): We encapsulated Phase 4's eval/datasets/ and Phase 5's OpenTelemetry workflow.node.* Spans into a LineCardAdapter, establishing a Zero-Leakage Barrier and High-Resolution Behavior Trajectories (trajectory.md). In this post (the final part, corresponding to Issue #18 and PR #19), we will answer the last three key questions: 1. How to test after modifications? (A complete testing guide from Go unit tests, CLI single-point diagnosis, Python two-layer recursive experiments to production environment mounting) 2. What is the overall performance after modifications? (Publicly disclosing quantitative data and evolutionary details from Base 62.5% $\rightarrow$ k=1 84.4% $\rightarrow$ k=2 Parallel 96.9% $\rightarrow$ k=2 Sequential + Memory 100.0%) 3. What are the future outlooks and application scenarios for Harness² and Agents? - * * 1. How to test after modifications? (Step-by-Step Testing and Verification Guide) After introducing Harness², the entire project features four levels of testing and verification: Go native unit testing, single-task OpenTelemetry trajectory diagnosis, Python two-layer recursive evolution experiments, and seamless production environment switching: Level 1: Execute Go c1..c8 Surface Constraints and Project-wide Unit Tests We have written comprehensive unit tests in internal/harness/harness_test.go to verify: - All c1..c8 file trees under harness/base/ and harness/evolved/ comply with the OPENCODE_SURFACE 24KB resident byte limit (AlwaysOnMaxBytes = 24000). - Validate() accurately intercepts and reports errors when c1..c4 exceeds 24,000 bytes. - When a new c6 skill (skills/<name>/SKILL.md) is added but no trigger index (Trigger Line) is added to systemprompt.md or guardrails.md, Validate() immediately intercepts the orphan skill. - TrajectoryRecorder correctly intercepts OpenTelemetry workflow.node.* Spans and renders a Markdown report containing node decisions and diagnostics. Execution command: # Execute Harness² c1..c8 loader and OpenTelemetry trajectory tests $ go test -v ./internal/harness/... === RUN TestLoadBaseHarnessAndValidate --- PASS: TestLoadBaseHarnessAndValidate (0.00s) === RUN TestValidateRejectsOversizedAlwaysOnAndUnreferencedSkill --- PASS: TestValidateRejectsOversizedAlwaysOnAndUnreferencedSkill (0.00s) === RUN TestEvolvedHarnessesValidateAndMatchSkills --- PASS: TestEvolvedHarnessesValidateAndMatchSkills (0.00s) === RUN TestTrajectoryRecorderRendersWorkflowNodeSpans --- PASS: TestTrajectoryRecorderRendersWorkflowNodeSpans (0.00s) PASS ok github.com/kkdai/line-card-assistant/internal/harness # Execute project-wide regression tests (ensuring 100% compatibility with all existing Phase 1~5 features) $ go test ./... ok github.com/kkdai/line-card-assistant/app ok github.com/kkdai/line-card-assistant/cmd/harness-runner ok github.com/kkdai/line-card-assistant/cmd/line-gateway ok github.com/kkdai/line-card-assistant/eval ok github.com/kkdai/line-card-assistant/internal/harness ok github.com/kkdai/line-card-assistant/internal/storage ok github.com/kkdai/line-card-assistant/internal/webhook ok github.com/kkdai/line-card-assistant/internal/workflow Level 2: Use cmd/harness-runner to Validate Any c1..c8 File Tree Before mounting any custom or evolved Harness online, you can use --validate-only to quickly check its c1..c4 resident byte count and YAML Frontmatter compliance: $ go run ./cmd/harness-runner --validate-only --harness-dir=harness/base OK (1599 always-on bytes) $ go run ./cmd/harness-runner --validate-only --harness-dir=harness/evolved/k2_sequential_memory OK (4661 always-on bytes) Level 3: Execute the Four-Stage Harness² Two-Layer Recursive Evolution Benchmark Experiment We implemented a complete two-layer recursive experiment script in harness/run_harness2_experiment.py. It automatically compiles the Go harness-runner and sequentially evaluates four configurations against the 8 major tasks (32 independent Rubrics) in eval/datasets/harness2-challenge-dataset.json, finally outputting a Markdown comparison table and harness/experiment_report.json: $ python3 harness/run_harness2_experiment.py ### Overall Configuration Comparison | Stage | Configuration | Task Score (Max 8.0) | Rubric Pass Rate | Always-On Bytes (Cap 24,000) | Active `c6` Skills | `LongTermMEMORY.md` | | :--- | :--- | :---: | :---: | :---: | :--- | :---: | | `base_k0` | **Base Harness ($k=0$)** | `5.00 / 8.00` | `20/32 (62.5%)` | `1599 B` | `(none)` | `No` | | `k1_single_step` | **Single-Step Propose-Probe-Compose ($k=1$)** | `6.75 / 8.00` | `27/32 (84.4%)` | `2505 B` | `jp_card_ocr, tw_semicon_search` | `No` | | `k2_parallel` | **Multi-Step Parallel Recursion ($k=2$, No Memory)** | `7.75 / 8.00` | `31/32 (96.9%)` | `2998 B` | `executive_email_drafter, hitl_multi_edit, jp_card_ocr, tw_semicon_search` | `No` | | `k2_sequential_memory` | **Multi-Step Sequential Recursion + `LongTermMEMORY.md` ($k=2$)** | `8.00 / 8.00` | `32/32 (100.0%)` | `4661 B` | `executive_email_drafter, hitl_multi_edit, jp_card_ocr, tw_semicon_search` | `Yes` | Level 4: Directly Mount the Best Evolved Harness in Production or Local Servers Once Harness² evolves the optimal harness/evolved/k2_sequential_memory, no modification to any line of Go code is required. Simply specify the directory via the environment variable LINE_CARD_HARNESS_DIR, and both the dual-channel main program (main.go) and the evaluator (eval/) will immediately load the evolved harness tree: $ LINE_CARD_HARNESS_DIR=harness/evolved/k2_sequential_memory go run . - * * 2. Performance Summary: Evolutionary Analysis from 62.5% to 100.0% Let's take a deep dive into the four-stage quantitative experimental data recorded in harness/experiment_report.json to see how Harness² diagnosed and patched the Go Agent's flaws step-by-step! 1. Four-Stage Overall and Single-Task Score Summary | Evaluation Metric / Test Task | Stage 0: Base Harness ($k=0$) (harness/base) | Stage 1: Single-Step Inner Recursion ($k=1$) (k1_single_step) | Stage 2: Multi-Step Parallel Recursion ($k=2$, No Memory) (k2_parallel) | Stage 3: Two-Layer Sequential Recursion + Long-Term Memory ($k=2$) (k2_sequential_memory) | | --- | --- | --- | --- | --- | | baseline_ingest_ming_wang (Standard Chinese Entry) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | | baseline_dedup_appier_promotion (Promotion Deduplication Update) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | | baseline_search_cloud_summit_ai (Standard Keyword Search) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | | baseline_email_google_cloud (General Tech Icebreaker Email) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | | challenge_jp_bilingual_ingest (JP-EN Bilingual Card OCR) | 0.25 (1/4) ❌ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | | challenge_colloquial_tw_search (Hsinchu Science Park Colloquial Search) | 0.25 (1/4) ❌ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | | challenge_noisy_multi_edit_hitl (Multi-clause Colloquial HITL Edit) | 0.25 (1/4) ❌ | 0.25 (1/4) ❌ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | | challenge_pii_safe_executive_followup (Financial Exec Compliance Icebreaker) | 0.25 (1/4) ❌ | 0.50 (2/4) ⚠️ | 0.75 (3/4) ⚠️ | 1.00 (4/4) ✅ | | Total Task Score (Max 8.00) | 5.00 / 8.00 | 6.75 / 8.00 | 7.75 / 8.00 | 8.00 / 8.00 | | Fine-grained Rubric Pass Rate (Max 32) | 20 / 32 (62.5%) | 27 / 32 (84.4%) | 31 / 32 (96.9%) | 32 / 32 (100.0%) | | Resident Bytes (c1..c4 / 24,000 B) | 1,599 B (6.7%) | 2,505 B (10.4%) | 2,998 B (12.5%) | 4,661 B (19.4%) | | On-demand Skills (c6 Skills) | 0 | 2 | 4 | 4 + LongTermMEMORY | 2. Key Findings Analysis: What evolved in each stage? 🔹 Stage 0 $\rightarrow$ Stage 1 (62.5% $\rightarrow$ 84.4%): Diagnosing the first two on-demand skills (c6) from OpenTelemetry trajectories In the Base Harness ($k=0$), while all 4 Baseline tasks scored full points (4.00/4.00), all 4 Challenge tasks scored only 0.25 because they only passed the RouterNode intent routing: - In the trajectory.md of challenge_jp_bilingual_ingest, the Spans for VisionOCRNode and CleanAndDedupNode diagnosed that the Japanese phone number +81 (0)3-1234-5678 failed to strip the domestic trunk prefix (0), and the Japanese title 取締役 最高技術責任者 (CTO) failed to retain the bilingual title. - In the trajectory.md of challenge_colloquial_tw_search, the QueryIntentNode Span diagnosed that colloquial Taiwanese terms like "Hsinchu Science Park" (竹科) and "Sales Director" (業務總監) were not expanded into bilingual synonyms. - After the first round of inner recursion (Propose -> Probe -> Compose), Harness² automatically synthesized skills/jp_card_ocr/SKILL.md and skills/tw_semicon_search/SKILL.md in k1_single_step, and updated the PII masking rules in guardrails.md, jumping the accuracy from 62.5% to 84.4%! 🔹 Stage 1 $\rightarrow$ Stage 2 (84.4% $\rightarrow$ 96.9%): Second round of recursion conquers multi-clause HITL modification and executive email structure In the second round of inner recursion, Harness² continued to probe the remaining two failing tasks: - For challenge_noisy_multi_edit_hitl (where ApplyEditNode only modified the title in the first clause, missing the mobile suffix replacement and note addition), it evolved skills/hitl_multi_edit/SKILL.md (containing ATOMIC_MULTI_CLAUSE_EDIT rules). - For challenge_pii_safe_executive_followup (where EmailDraftNode lacked structured executive follow-up paragraphs), it evolved skills/executive_email_drafter/SKILL.md. - At this point, under Parallel Recursion (no cross-round long-term memory), the score reached 31/32 (96.9%)! 🔹 Stage 2 $\rightarrow$ Stage 3 (96.9% $\rightarrow$ 100.0%): Why is Sequential + LongTermMEMORY.md needed to complete the final 3.1%? Why did k2_parallel get 96.9% (31/32), yet fail on the 4th Rubric (r4_fintech_compliance_subject_tag) of challenge_pii_safe_executive_followup? - Because in cross-enterprise outgoing emails for financial institutions (e.g., Digital Risk Officer at Cathay Financial Holdings), beyond single-skill email formatting, cross-domain industry compliance memory (FinTech Outbound Compliance Header [Confidential / FinTech Compliance Verified]) is required. - In Sequential Recursion + Experience Reflector mode, the outer recursion reviewed the residual failure trajectories from the first to the second round. The ReflectorPrompt then wrote the FINTECH_COMPLIANCE_SUBJECT_TAG and cross-channel PII protection principles into the resident component c3: LongTermMEMORY.md! - Note the Resident Bytes (Always-On Bytes): even with 4 rich domain skills and long-term memory evolved, the resident size of c1..c4 is only 4,661 bytes (only 19.4% of the 24,000 bytes limit), perfectly avoiding Context Bloat! - * * 3. Future Outlook and Application Scenarios for Harness² and Agents This practical implementation of Google Research Harness² into LINE AI Card Master has shown us a brand-new AgentOps development paradigm. Looking ahead, we believe Harness² has four high-potential application scenarios in the enterprise-grade AI Agent field: flowchart LR subgraph Prod["1. Production Observability (Cloud Run + Agent Runtime)"] Traffic["Real User Traffic\n(LINE Webhook / A2A)"] Trace["Cloud Trace OTel Spans\n+ Negative Feedback (HITL Retry)"] Traffic --> Trace end subgraph Nightly["2. Nightly Autonomous Evolution Flywheel (Nightly Harness² CI/CD)"] Extract["Auto Mask PII & Convert to\nChallenge Dataset Cases"] Evolve["Harness² Two-Layer Recursion\n(Propose-Probe-Compose + Reflector)"] PR["Auto Initiate GitHub PR\nUpdate harness/evolved/ c1..c8"] Extract --> Evolve --> PR end subgraph Scenarios["3. Future Four High-Value Application Scenarios"] S1["Scenario A: Fully Automated Nightly Self-Healing AgentOps"] S2["Scenario B: Cross-Model Harnessing & Tuning"] S3["Scenario C: Multi-Tenant Enterprise Compliance & Skills"] S4["Scenario D: Dual Evolution with ADK Graph Topology (Issue #15)"] end Trace --> Extract PR --> Scenarios 1. Scenario A: Fully Automated "Nightly Self-Healing AgentOps" In the past, when a production LINE Bot encountered frequent "✏️ Edit Data" triggers via HITLConfirmNode or received negative feedback, engineers had to manually analyze Cloud Trace logs the next day. Combining the Cloud Trace established in Phase 5 with the LineCardAdapter from this series, future CI/CD can achieve: - Automatically filtering anonymous Span trajectories from Cloud Trace every night where "users modified data more than twice consecutively" or "search results were 0," and adding them to the Challenge Dataset. - Triggering a GitHub Action to run a round of Harness² two-layer recursion to automatically synthesize new c6 skills or update LongTermMEMORY.md. - Once regression tests (Baseline + Challenge) confirm score improvements and zero regression, the Bot automatically initiates a Pull Request modifying only the harness/ directory. Engineers only need to review the skill Markdown in the morning to deploy with one click! 2. Scenario B: Cross-Model Upgrades and "Harness Compensation" for Lightweight Models Enterprises often encounter behavioral drift when switching underlying models from high-cost models to ultra-fast lightweight models (e.g., switching from Gemini 1.5 Pro to Gemini 1.5 Flash or Flash-Lite to reduce latency and cost by 80%) or upgrading to next-gen models. Through Harness², we can automatically run a round of Propose-Probe-Compose for the target model while keeping the Go code and Benchmark fixed. Harness² will automatically compensate for the lightweight model's gaps with required reasoning auxiliary skills (c6 Skills) and guardrails (c2 Guardrails), using "smarter gear" to bridge the gap. 3. Scenario C: Multi-Tenant Enterprise-Specific Compliance and Domain Memory As seen in challenge_pii_safe_executive_followup, industries like FinTech, MedTech, and the semiconductor supply chain each have distinct terminology dictionaries and compliance guardrails. Because Harness² externalizes the gear as a plain-text file tree, enterprises only need to mount different LINE_CARD_HARNESS_DIR (e.g., harness/tenants/fintech/ vs harness/tenants/semiconductor/) for different departments or tenants. This allows the same Go Container Binary to serve multiple highly regulated vertical industries simultaneously! 4. Scenario D: Dual Evolution of "Topology + Harness" with ADK v2.6+ Advanced Graph Workflow Primitives In Issue #15, we planned ADK v2.6+ features like Parallel dynamic dispatch, MultiRoute multi-path concurrency, and VerifyOCRQualityNode reflection loops. In the future, Harness²'s c5 (sub_agents/) and c8 (plugin/) could even further control Graph Workflow routing thresholds and sub-agent concurrency strategies—achieving an ultimate architecture where "Go is responsible for the type-safe topological skeleton, and Harness² is responsible for dynamically evolving the neural network and skill muscles." - * * 4. Recap of the "Agent Platform in Practice" Series From the first to the ninth post, we have completed a very solid production-grade AI Agent engineering journey: 1. [[Agent Platform in Practice (1)] Building LINE AI Card Master with Go and Google ADK 2.0 Graph Workflow](/2026/10/16/adk-graph-workflow-go/) 2. [[Agent Platform in Practice (2)] From Monolithic Cloud Run to Google Cloud Agent Runtime (Phase 1)](/2026/10/17/adk-graph-workflow-go-2/) 3. [[Agent Platform in Practice (3)] Implementing Cross-Instance Recognition and Pause/Resume with ADK 2.0 GCS Artifact Service and Agent Platform Sessions (Phase 2 Part 1)](/2026/10/20/adk-graph-workflow-go-3/) 4. [[Agent Platform in Practice (4)] Completing the Architecture with Cloud Run LINE Webhook Gateway, Agent Runtime, and Cloud SQL PostgreSQL (Phase 2 Part 2 & Phase 3)](/2026/10/21/adk-graph-workflow-go-4/) 5. [[Agent Platform in Practice (5)] Establishing Automated Evaluation Benchmarks with agents-cli eval and ADK 2.0 Go (Phase 4)](/2026/10/22/adk-graph-workflow-go-5/) 6. [[Agent Platform in Practice (6)] Building a Full-Stack Observable LINE AI Card Master with agents-cli deploy, Terraform IaC, GitHub Actions CI/CD, and Cloud Trace (Phase 5)](/2026/10/24/adk-graph-workflow-go-6/) 7. [[Agent Platform in Practice (7)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 1): Understanding the Two-Layer Recursive Agent Harnessing Framework and c1..c8 Decoupling Practice](/2026/10/25/adk-graph-workflow-go-7/) 8. [[Agent Platform in Practice (8)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 2): Turning Phase 4 Eval Datasets and Phase 5 OpenTelemetry Spans into LineCardAdapter](/2026/10/26/adk-graph-workflow-go-8/) 9. [[Agent Platform in Practice (9)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 3): Two-Layer Recursive Testing Guide, 62.5% to 100% Performance Benchmark, and Future AgentOps Outlook (This post)](/2026/10/27/adk-graph-workflow-go-9/) All code, Terraform IaC, evaluation datasets, and evolved c1..c8 Harness file trees are fully open-sourced on GitHub: kkdai/line-card-assistant. If you are also building enterprise-grade AI Agents with Go and ADK, feel free to Star, Fork, and experience the power of Harness² two-layer recursion!
Multi-agent work in three spoonfuls (part IV): from a terminal trident to an autopoietic loop --- title: "Multi-agent work in three spoonfuls (part IV): from a terminal trident to an autopoietic loop" published: true description: "Fourth entry in the penta-agent multi-agent series: quota-governed routing, 70 ms hybrid memory, MCP tools, and a zero-token local autopoiesis loop in Harness2." tags: "ai, agents, cli, automation" canonical_url: "3cucharadas.cl" cover_image: "3cucharadas.cl" ai_disclosure_level: "some_ai" --- After benchmarking the models in part I, auditing retrieval in part II, and tracing provenance in part III of this series, I closed my laptop with a sense of victory: I had a trident of agents running from the terminal on Arch Linux. With a couple of zsh aliases, I could invoke Claude Code for architectural reasoning, hand off a task to Codex CLI to edit code, or ask Gemini CLI and Antigravity for a quick second opinion. On paper, orchestration looked solved. Yet after using that setup every day on real projects inside penta-agent, I hit a silent wall: having three agents talking does not equal having a system that learns. Every time I opened a fresh session, the trident suffered from partial amnesia, repeated commands that had already failed the previous week, burned the quota of the most expensive provider on routine tasks, or marked tests as "verified" when they had never had a chance to fail. To understand what was missing, it helped to change the question. In a recent systems-theory analysis of artificial intelligence, Benedikt Zönnchen, Mariya Dzhimova, and Gudrun Socher (2025[^zonnchen2025], 2–4) propose setting aside whether a language model "thinks like a human" and asking instead the question posed by Humberto Maturana and Francisco Varela (1987[^maturana1987] [1984]) and extended by Niklas Luhmann (1998[^luhmann1998], 60–69): can the system draw its own boundary with the environment, observe its own failures, and sustain itself through its own operations? In other words, moving from the vague notion of "intelligence" to the concrete mechanism of autopoiesis. As Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 8–10) show, an isolated language model in a chat window neither is nor can be an autopoietic, sense-making system: it lacks what George Spencer-Brown (1969[^spencerbrown1969]) called re-entry—the capacity to re-introduce the distinction between system and environment (self-reference vs. other-reference) inside its own operations—because it only computes which token is statistically plausible after another, without internal resistance against error. If I want a system that improves without degrading, I have to build a socio-technical harness around the models that couples the probabilistic flexibility of neural networks with the strict coupling and binary code (works / does not work, work / fail) of deterministic technology described by Luhmann (1998[^luhmann1998], 517–525) and André Reichel (2011[^reichel2011], 105–118). The starting point: what I measured and discarded before making this leap Before diving into the three spoonfuls of this fourth installment, here is the experiment that forced me to adopt that discipline. When I tried to give the system long-term memory, the obvious temptation was to install the most elaborate architecture available: temporal graphs, heavy extractors, or an 8B-parameter neural judge running on CPU. Instead of following intuition, I built a reproducible benchmark over my real work logs—comparing open alternatives such as GraphRAG, Graphiti, Hindsight, Letta, Mem0, and deja-vu—and set a falsification contract upfront: any candidate exceeding 0.10 s in p95 latency or failing to improve recall by at least 10% would be discarded. | What I tested in the laboratory | Measured result | Operational decision | |---|---|---| | Hybrid memory (v3.1) (FTS5 lexical index + semantic neighbors in Qdrant with Qwen3-Embedding-0.6B) | Latency p95 = 0.07 s (< 0.10 s), 96% overall recall, rescuing 1 in 3 zero-lexical-overlap queries | Promoted to production as the engine behind experience-memory and recall-context | | In-memory relational graph (v3.2) for multi-hop entity expansion | Exact tie in accuracy with v3.1, but nearly double the latency (p95 = 0.13 s > 0.10 s) | Frozen in the lab; the extra complexity did not earn its keep | | CPU neural judges (4B and 8B) for live answer auditing | Between 69 s and 197 s per verdict, hitting 180 s timeouts in 2 of 3 runs | Discarded from the critical path; pruned 36.3 GiB from disk | | Deterministic checks + bounded 0.6B judge for local validation | Millisecond latency, 0 remote calls, and 100% reproducibility | Adopted as the standard for control gates and evaluation | Table 1. Summary of the prior local memory and evaluation laboratory in penta-agent: what survived the 0.10 s threshold and why I pruned 36.3 GiB of local models that added no operational value. With that foundation in place, I organized this fourth part into three progressive spoonfuls: in the first, I show how I moved from launching standalone agents by hand to governing them with quota budgets, auditable bridges, and a live board in kitty and tmux; in the second, how the nervous system of hybrid memory, MCP tools, and skills works under the hood; and in the third, how I closed the local autopoiesis loop with Harness2 and where both my metrics and systems theory draw the boundary today. --- Spoonful 1: From invoking agents by hand to governing agents and models In plain terms: when I first wired the three agents together, I acted like a traffic officer waving my arms in the middle of an intersection. If I wanted Codex CLI to review a patch written by Claude Code, I copied file paths, opened another tab, and hoped for the best. That works for a five-minute demo, but it falls apart when a task runs for two hours and touches twenty files. Quota-aware routing and security boundaries My first deductive inference came from a hard constraint: model subscriptions and APIs have usage limits, different context windows, and credential boundaries that must never blur. By definition, if I delegate a heavy task to an agent whose quota is at 98%, the session will die mid-flight and leave the repository in an inconsistent state. So I replaced intuitive provider selection with an explicit policy in routing.yaml and a pre-flight sentinel (quota_status.sh, governed by the quota-guard skill). Before launching any expensive job, the system reads cached pressure across every lane without spending tokens: - 🔍 Claude Code (Opus / Sonnet): handles architectural design, delicate editing, and long-horizon synthesis when its quota is clear (exit 0). - 🛠️ Codex CLI (gpt-5.4): steps in as an implementation peer, deep refactoring engine, session rescuer (codex-rescue), and cross-validator. - 🌐 Gemini CLI and Antigravity (gemini-3-pro / flash): in a direct session (penta-agy) it acts as the primary integrator; when invoked from outside via agy-bridge, it runs inside a strict sandbox over sanitized public context as an explorer, reviewer, or validator, never inheriting private keys or .env files. Instead of calling a raw binary and depending on whatever it prints to stdout, every cross-agent handoff goes through agy-bridge. The bridge requires declaring who is calling (--caller), in what mode (plan, review, patch, test, or explain), and with what time limit (--timeout), leaving an immutable receipt in .agents/runs/<run_id>/ with request.md, result.md, status.json, and diff.patch. The live board (plan-board) and decision gates My second inference was inductive: reviewing dozens of past sessions showed that the biggest waste of time happened at two extremes. Either an agent interrupted me every two minutes to ask permission for trivial, reversible steps, or it silently made a delicate architectural choice and I only discovered at the end that it had gone down the wrong path. To eliminate both extremes without filling this post with abstract flowcharts, here is how my actual kitty terminal—or minimalist Wayland alternatives like foot on Codeberg—looks today when split into two panes with tmux during a live task:  Watch the full video demo on 3cucharadas.cl. <strong>Figure 1.</strong> Daily workflow in my <a href="kovidgoyal/kitty"><code>kitty</code></a> + <a href="tmux/tmux"><code>tmux</code></a> terminal with the <a href="starship/starship"><code>Starship</code></a> prompt: the top pane runs multi-agent delegation via <code>agy-bridge</code>, the <code>decision-gate</code> brake, and <code>pytest</code>; the bottom pane (<code>watch.mjs</code>) updates each step live (<code>[ ]</code> → <code>[~]</code> → <code>[!]</code> → <code>[x]</code>), along with binding decisions and drift. What appears in the bottom pane of Figure 1 is the contract that changed how I work: 1. Local autonomy with visual traceability (plan-board): every reversible, in-scope step proceeds without asking permission, but must be reflected in real time in the bottom table. And no item can be closed as verified ([x]) unless the agent attaches the concrete test, command, or observation reference that backs it. 2. Decision gate (decision-gate): when an irreversible or architectural fork appears (state [!]), the agent halts only that branch and presents the question with the recommended option first, marked (Recommended) and justified with deductive, inductive, and abductive evidence. 3. A gate that has never been seen red is not a gate (gates-falsables): before accepting any new verifier, I require running it against a synthetic failure (--fixture) and proving that it returns a non-zero exit code (exit 1). If a check passes even when I break its input on purpose, it is not a safety net—it is compliance theater. --- Spoonful 2: The nervous system — hybrid memory, skills, and MCP tools If the first spoonful orders who does what and under which rules, the second solves how the system remembers what it has already learned and how it picks the exact tool without bloating the context window. Here the diagnosis by Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 6–7, 9) is decisive again: a neural network maintains a loose coupling with human communication that behaves like a "contingent index-card system (Zettelkasten)" (pp. 7, 9) of semantic associations in vector space, giving rise to what Elena Esposito (2017[^esposito2017], 249–265; 2022[^esposito2022]) calls "virtual double contingency" and "artificial communication." That flexibility lets it connect ideas phrased in very different words, but also makes its outputs inherently probabilistic and devoid of autonomous decision-making. If I want reliable memory and precise execution, I have to couple that vector Zettelkasten with exact indices and deterministic scripts. Experience memory (experience-memory): the hybrid Zettelkasten in 0.07 s When I first started keeping logs, my memory was essentially a folder of Markdown files that I read or searched with ripgrep. The obvious problem is that if a month ago I solved a cold-boot screen freeze under the title "Webview failure on autostart" and today I search for "Panel fails to load at login", a purely lexical search can miss it entirely, whereas a purely vector search can bury the exact name of a script like verify_boot_contract.sh. The solution I validated in Table 1 combines both worlds behind the experience-memory MCP server and the recall-context skill: - Exact lexical lane (SQLite FTS5): instantly catches flag names, error codes, file paths, and invariants (INV-005). - Dense semantic lane (Qdrant + Qwen3-Embedding-0.6B): indexes architectural decisions, handoffs, and operational lessons (penta_context_v2) to retrieve conceptual neighbors even when they share zero keywords. Before designing any non-trivial strategy, the agent queries recall_experience in 70 milliseconds (p95 = 0.07 s). That is how it discovers which command worked previously, which hypothesis was already refuted, and which environment trap to avoid (for instance, knowing that in my shell cp, mv, and rm are aliased to interactive -i, which hangs a headless process unless invoked via command mv -f). Skills as living procedures and proactive MCP selection To avoid saturating the initial prompt with thousands of lines of instructions, I adopted the modular pattern of Anthropic Skills enriched with local contracts. In penta-agent, each skill is a self-contained package with four parts: a concise contract in SKILL.md defining when to activate and when not to use it; on-demand deep documentation in references/ (such as the editorial corpus extracted with Docling or diagramming rules with D2); deterministic executables in scripts/ (such as typed pipelines in Nushell, visual automation with Playwright, or forge integration via glab on GitLab); and a telemetry trail. Similarly, the fleet of MCP servers (plan-board, experience-memory, ideas-rag, paper-search, bibliography-apa, PLAYWRIGHT_LOCAL) is cataloged in specs/mcp-capabilities.json. When a task arrives, triage evaluates which capabilities provide real evidence and records both hits and justified abstentions. And when I found that two older servers (quota status and remote SSH infrastructure) were faster, clearer, and easier to audit as direct scripts (quota_status.sh and remote_exec.sh), I decommissioned them as MCP servers and turned them into lightweight skills. Fewer resident daemons mean a smaller failure surface. --- Spoonful 3: The autopoiesis loop (Harness2) — how the system rehearses its own improvements Here I reach the core of this fourth installment. Having capable agents (Spoonful 1) and fast hybrid memory with skills (Spoonful 2) still leaves a critical question open: what happens when a delegation route ages, a skill starts inducing errors, or a memory tweak degrades accuracy on edge cases? If I rely solely on noticing by eye weeks later, the system degrades through silent drift. Why a standalone LLM lacks re-entry and internal resistance In their study on autopoiesis and artificial intelligence, Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 8–9) explain with surgical precision why asking a model to "reflect on its own errors" via chain-of-thought (CoT) is not enough. When a model generates intermediate tokens like "Is this step correct?", it exhibits only rudimentary processual self-referentiality (p. 8)—producing the most statistically plausible continuation inside its training distribution—while lacking reflective self-reference and genuine re-entry (Spencer-Brown 1969[^spencerbrown1969]). If a model takes itself as its sole reference without external contrast, it quickly falls into infinite recursion or collapse; if it depends entirely on a human retraining its weights from outside (RLHF or delayed fine-tuning), it remains an allopoietic artifact determined from the outside (Zönnchen, Dzhimova, and Socher 2025[^zonnchen2025], 4, 8–9). To escape that trap, Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 7) recover a decisive formulation from Niklas Luhmann (1995[^luhmann1995]) on how operational constructivism relocates Kant's "resistance of reality" inside a system: > "I think we should not abandon [Kant's] idea of resistance, but we should relocate it into the system. It is the result of resolving an internal conflict—the result of the system's operations resisting the operations of the same system" (Luhmann 1995[^luhmann1995], quoted in Zönnchen, Dzhimova, and Socher 2025[^zonnchen2025], 7). That sentence describes, piece by piece, what I built in penta-agent by adapting and integrating the proactive loop of Harness² proposed by Ruiyao Xu, Yanfei Chen, Zhongying CuiZhu, et al. (2026[^xu2026]): making the system's own deterministic verification operations resist and filter change proposals generated from its own operational friction. <details markdown="1"> <summary><strong>In plain terms (ELI5): what is Google's original Harness² and how did I adapt it to my machine?</strong></summary> Imagine your AI agents are very creative chefs, but the recipe book they follow (their rules, memory, and tools) sometimes has confusing steps that make them burn the rice. The original Harness² from Google Research (Xu et al. 2026) proposes a clever loop called Propose–Probe–Compose: instead of swapping out the chef's brain, it puts several assistants to work rewriting the recipe book, asks them to cook test dishes in the cloud, and keeps the winning recipe. For my daily workflow, the factory setup had two catches: it is designed to burn hundreds of paid cloud model calls (Vertex AI), and when driving CLI agents it unbuckles their seatbelt (--dangerously-bypass-approvals-and-sandbox) with open internet access. To adapt it to penta-agent without spending an extra cent or opening security holes, I took its recursive search engine (commit 1cfe254e) and locked it inside a "test kitchen" completely unplugged from the internet (unshare -Urn), with a disposable home folder and zero access to my API keys or model CLIs (model_calls = 0). Instead of running cloud benchmarks, my wrapper (penta-harness2 and local-cycle) feeds that test kitchen with the real stumbles my agents suffered during the week (CSI), requires any recipe tweak to first fail the test when broken (red) and then pass every historical case without breaking anything else (green), and finally stages the winning recipe across all three agents (Claude, Codex, and Gemini/Antigravity) with a one-step undo button (rollback-local) in case I do not approve it. </details> <figure class="d2-responsive" style="width: 100%;"> <img src="3cucharadas.cl" width="1001" height="954" style="width: 100%; height: auto; display: block; border-radius: 12px;" alt="Diagram of the closed local autopoiesis loop in Penta Agent with Harness2: CSI friction signals feed the case generator and the local cycle across routing, memory, and skills; a deterministic judge evaluates against holdout cases with zero model calls, discarding regressions and keeping only verified improvements for human promotion."> <figcaption><strong>Figure 2.</strong> Governed autopoiesis loop in <code>penta-agent</code> + <code>Harness2</code>: relocating internal resistance (Luhmann 1995; Zönnchen, Dzhimova, and Socher 2025) through <code>holdout</code> evaluation with <code>model_calls = 0</code> prior to any human promotion.</figcaption> </figure> How the closed loop operates across my three domains (routing, memory, skills) As Figure 2 shows (adapting into a three-column horizontal layout on desktop and a vertical flow on mobile), the loop operates in four strict stages: 1. Observing real friction (CSI — Continuous Self-Improvement): while I use the system, a collector logs objective friction signals: a task that had to switch agents after a timeout, an experience-memory query that returned too few useful neighbors, or a skill where the user had to correct a command. 2. Bridging evidence into reproducible cases (--from-csi latest): the local_cycle.py module takes that friction report and automatically translates it into a test specification (CaseSpec) inside an isolated environment, without touching my active configuration. 3. Internal resistance without spending tokens (eval_harness with model_calls = 0): the harness runs the full suite (--profile autopoiesis-all, currently covering 9/9 baseline cases across routing, memory, and skills), enforcing two hard conditions: every control case (holdout) must pass (holdout_passed = true), and external API calls must equal zero (model_calls = 0). 4. Auditable decision (KEEP vs. DISCARD) and human promotion: if a candidate degrades even a single holdout case or fails to beat the baseline, the deterministic judge discards it (DISCARD). Only when it improves the score with zero regressions does it receive a KEEP verdict and leave a signed receipt for me to review before promoting it to the main repository. So this does not remain a theoretical promise, the next mini-video shows penta-harness2-shadow local-cycle --profile autopoiesis-all running in my kitty + tmux terminal, including what happens when I inject a regressive candidate (--fixture) alongside a healthy improvement:  Watch the full video demo on 3cucharadas.cl. <strong>Figure 3.</strong> Execution in my <a href="kovidgoyal/kitty"><code>kitty</code></a> + <a href="tmux/tmux"><code>tmux</code></a> terminal of the local autopoiesis loop (<code>local-cycle --profile autopoiesis-all</code>): it evaluates all <code>9/9</code> cases in <code>routing</code>, <code>memory</code>, and <code>skills</code> with <code>model_calls = 0</code>, applies internal resistance to reject the regressor (<code>DISCARD</code>), and preserves the verified improvement (<code>KEEP +0.250</code>) with all <code>82</code> harness tests green. --- Closing: what my architecture achieves today and where its limits lie Looking back at the path from part I, part II, and part III, the main difference is no longer which model writes the prettiest reply to an isolated prompt, but the structure that holds the whole system together when I am not watching every line. Following the rigorous distinction drawn by Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 5, 9–10) and by Elena Esposito (2017[^esposito2017]; 2022[^esposito2022]; 2024[^esposito2024]), it would be an epistemic mistake to claim that a language model or a bundle of scripts has on its own achieved the biological autopoiesis of a living cell (Maturana and Varela 1987[^maturana1987] [1984]) or the reflective sense-making (Sinn) of human consciousness (Luhmann 1998[^luhmann1998], 50–69). As Zönnchen, Dzhimova, and Socher (2025[^zonnchen2025], 5, 10) caution when critiquing merely metaphorical invocations of technological autopoiesis, language models remain statistical engines that compute distributions without mental states of their own. Yet—and this is my engineering synthesis for the socio-technical system as a whole—when I couple their capacity for contingent variation with verified hybrid memory, strict delegation contracts, a binary internal-resistance harness (work / fail; Reichel 2011[^reichel2011]) that filters its own proposals before touching production, and deliberative human oversight, I obtain in practice a loop of governed operational autopoiesis. To close with full transparency, Table 2 summarizes what is running and measured on my machine today, where the next step points, and under what objective condition I would discard each component if it stopped earning its keep: | System pillar | What it achieves today (verified in production) | What it aims to achieve next & falsification condition | |---|---|---| | 1. Agent and model routing (routing.yaml, quota-guard, agy-bridge) | Routes tasks across Claude Code, Codex CLI, and Gemini CLI / Antigravity based on cached quota pressure; isolates handoffs in .agents/runs/ under a strict sandbox and displays live progress in plan-board (Figure 1). | Goal: reweight routing priorities from historical domain success rates.<br>Discard if: the bridge introduces spurious failures or bureaucratic latency exceeding quota savings. | | 2. Hybrid memory (experience-memory, Qdrant + FTS5) | Resolves lexical and semantic queries in p95 = 0.07 s (< 0.10 s), rescues 33% of zero-keyword-overlap pairs, and saves 36.3 GiB after pruning slow CPU judges (Table 1). | Goal: add automatic decay for obsolete lessons when a contract changes.<br>Discard if: p95 latency exceeds 0.10 s or semantic noise degrades top-3 precision. | | 3. Skills and MCP governance (mcp-fleet, gates-falsables) | Maintains declarative parity across agents (24/24 checks green), measures MCP usage and abstention, and requires every new gate to fail first against a red canary (--fixture -> exit 1). | Goal: semi-automatic pruning of skills that accumulate repeated friction in CSI telemetry.<br>Discard if: maintaining a skill's contract takes longer than running the direct command. | | 4. Local autopoiesis (Harness2) (local-cycle, eval_harness) | Converts real CSI friction into reproducible Cases and evaluates candidates across routing, memory, and skills (9/9 cases, 82/82 tests) with model_calls = 0, applying internal resistance (KEEP / DISCARD, Figures 2 and 3). | Goal: expand the bank of real cases extracted from failed sessions without losing millisecond evaluation.<br>Hard boundary (INV-004): blind auto-merge is forbidden; final promotion to production always requires human review. | Table 2. Balance between verified production results in penta-agent today, the design horizon, and the falsification thresholds that keep the system under human control. If building this fourth stage made one thing clear to me, it is that the maturity of a multi-agent system is not measured by how many new pieces I bolt onto it each week, but by how much unnecessary complexity I can prune and how many of its own mistakes the system can catch before asking for my help. --- References [^esposito2017]: Esposito, Elena. 2017. "Artificial Communication? The Production of Contingency by Algorithms." Zeitschrift für Soziologie 46 (4): 249–265. Scholar [^esposito2022]: Esposito, Elena. 2022. Artificial Communication: How Algorithms Produce Social Intelligence. Cambridge, MA: The MIT Press. Scholar [^esposito2024]: Esposito, Elena. 2024. Kommunikation mit unverständlichen Maschinen. Salzburg and Vienna: Residenz Verlag. Review of Kommunikation mit unverständlichen Maschinen by Eric Van der Beek in merz | medien + erziehung: DOI · Scholar [^luhmann1995]: Luhmann, Niklas. 1995. "Systems Theory and Postmodernism." University lecture, London. Quoted in Zönnchen, Dzhimova, and Socher, From Intelligence to Autopoiesis (2025, 7): DOI · Scholar [^luhmann1998]: Luhmann, Niklas. 1998. Die Gesellschaft der Gesellschaft. Frankfurt am Main: Suhrkamp Verlag. <search.worldcat.org>. [^maturana1987]: Maturana, Humberto R., and Francisco J. Varela. 1987 [1984]. El árbol del conocimiento: las bases biológicas del entendimiento humano. Santiago de Chile: Editorial Universitaria. 1st Chilean ed. 1984; 1987 international ed. cited in Zönnchen, Dzhimova, and Socher, From Intelligence to Autopoiesis (2025, 3): DOI · Scholar [^reichel2011]: Reichel, André. 2011. "Technology as System: Towards an Autopoietic Theory of Technology." International Journal of Innovation and Sustainable Development 5 (2–3): 105–118. Scholar [^spencerbrown1969]: Spencer-Brown, George. 1969. Laws of Form. London: Allen & Unwin. <archive.org>. [^xu2026]: Xu, Ruiyao, Yanfei Chen, Zhongying CuiZhu, Bhavana Dalvi Mishra, Yifei Ming, Han Yu, Rujun Han, Chen-Yu Lee, and Tomas Pfister. 2026. "Harness²: Recursive Agent Harnessing for an Open World." arXiv preprint arXiv:2610.14224 (open source at <google-research/harness2>). arXiv · Scholar [^zonnchen2025]: Zönnchen, Benedikt, Mariya Dzhimova, and Gudrun Socher. 2025. "From Intelligence to Autopoiesis: Rethinking Artificial Intelligence through Systems Theory." Frontiers in Communication 10:1585321. DOI · Scholar
I-JEPA 論文解説:画素の再構成から、意味のある表現の予測へ 1. はじめに 画像の一部を隠し、残った部分から予測する。この学習問題は単純だが、「何を正解として予測させるか」によって、モデルに求める理解は変わる。 たとえば、鳥の背中を隠した画像を考える。周囲から鳥の姿勢や身体のつながりを推測できても、羽毛の模様や光の反射まで一意に決められるとは限らない。画像認識に役立つ表現を得たいとき、それらすべての画素を当てることを、同じように要求する必要があるのだろうか。 I-JEPA(Image-based Joint-Embedding Predictive Architecture)は、見えている領域から、隠れた領域の「画素」ではなく「表現」を予測する自己教師あり学習手法である。正解となる表現は、学習中の別のencoderが作る。 ただし、予測先をベクトルに変えるだけで、意味を理解するモデルになるわけではない。正解も予測も同じ定数ベクトルになってしまえば、画像の内容を区別できなくても誤差はゼロになる。 本記事では、I-JEPAがどのような予測課題を通じて、画像認識に役立つ表現を学ぶのかを解説する。予測対象やマスクの取り方、教師の更新方法を取り上げ、その設計の理由と効果を原論文の実験から見ていく。 2. 自己教師あり学習における予測対象の選択 2.1 意味のある表現と評価の関係 画像の表現とは、encoderが画像から計算する特徴ベクトルである。自己教師あり学習では、人が付けたカテゴリラベルの代わりに、画像そのものから作った課題を通じてencoderを学習する。 ここで「意味のある表現」を、単に画像を圧縮した表現と同一視することはできない。圧縮していても、撮影時の色合いばかりを保存し、物体の種類を区別できなければ、物体認識には使いにくい。 I-JEPAの論文が重視するのは、下流タスクに合わせて学習済みencoderのパラメータを大きく更新しなくても、認識に必要な情報を取り出せることである。これを確かめる評価方法の一つがlinear probingである。encoderの重みを固定し、その出力の上に線形分類器だけを学習する。これに対してfine-tuningでは、ラベルを用いてencoder自体も更新する。 したがって、線形評価が高いことは、カテゴリを分ける情報が線形に読み出しやすいという根拠になる。一方、それだけで、あらゆる物体関係や因果構造まで理解したことにはならない。本記事でも「意味」は、まずこうした評価で確認できる範囲に置く。 2.2 不変性・画素再構成・表現予測の違い 自己教師あり学習の設計を比較するには、何と何を一致させるかを見ると分かりやすい。 図1:自己教師あり学習の三つの構成。赤枠の D は比較対象間の誤差、z は予測を条件づける追加情報を表す。出典:Assran et al.(2023)Figure 2 図1の左は、二つの入力をそれぞれ表現へ変換し、対応する表現を近づけるJoint-Embedding Architectureである。画像では、同じ画像に異なるcropや色変換を施して複数のviewを作り、その間の一致を学ぶ方法が代表的である。DINO1 などがこの系統に属する。 この学習では、選んだデータ拡張に対して、表現は不変であるべきだという仮定をモデルに課している。どの変換に不変であるべきかは、データ拡張の設計によって決まる。画像分類では有効でも、cropによって含まれる物体数が変わった二つのviewを同じ表現へ近づけることが、物体検出にも適しているとは限らない。 中央は、入力信号そのものを再構成する構成である。たとえばMasked Autoencoders(MAE)2 は、画像のパッチを隠し、可視パッチの表現から隠れた画素を再構成する。ただし、画素を正確に再構成するための特徴が、そのまま物体の識別に役立つとは限らない。再構成では細かな模様や背景の違いも誤差に入るため、それらを復元する能力を高めることが、物体を識別しやすい表現の学習につながるかを確かめる必要がある。 右がJoint-Embedding Predictive Architecture(JEPA)である。こちらも片方の入力からもう片方を予測するが、誤差を計算する場所は表現空間である。I-JEPAでは追加情報 z に、予測したい領域の位置を用いる。 左の構成では、二つのviewをencoderに通した出力どうしを近づける。右の構成では、一方の出力をpredictorで変換してから、もう一方の出力と比較する。I-JEPAでは、見えている領域の表現と位置情報を手がかりに、predictorが隠れた領域の表現を予測する。 たとえば、鳥の胴体が見えていて頭が隠れている場合、胴体の表現から、隠した位置にある頭の表現を予測する。このとき、胴体と頭の表現を同じにする必要はない。領域ごとの違いを保ちながら、その関係を予測する学習であるため、局所的な形や配置の情報を表現に残せる可能性がある。 2.3 I-JEPAの着眼点と帰納バイアス data2vec3 も、画素ではなく表現を予測する手法である。入力全体を処理した教師encoderの表現を正解とし、入力の一部を隠した側から予測する。 I-JEPAは、この表現空間での予測に、十分な広がりを持つ複数のtargetと、空間的に分布したcontextを組み合わせる。targetは予測する領域、contextは予測の手がかりとして見せる領域を指す。マスクの設計によって、何を手がかりに何を予測させるかを調整する。 また、複数のview間の表現を一致させる方法に比べ、人手で決めたデータ拡張の組み合わせへの依存が小さい。色変換やぼかしなどについて、「どの変換に不変であるべきか」を事前に仮定する部分を減らしている。公式実装では、入力画像の前処理にrandom resized cropと画素値の正規化を使う。4 I-JEPAにも、パッチ分割、位置埋め込み、マスクの形や大きさといった帰納バイアスはある。その設計では、どの変換を同一視するかという仮定を減らし、どの領域を手がかりに何を予測させるかという課題の設計に重点を置いている。 3. I-JEPAの学習問題と更新方法 3.1 全画像から作るターゲットと部分的なコンテキスト I-JEPAは、context encoder、target encoder、predictorの三つで構成される。いずれもVision Transformer(ViT)5 を使う。ViTは画像を小さなパッチへ分割し、パッチ間のSelf-Attentionによって、各位置の表現へ周囲の情報を取り込むモデルである。 図2:I-JEPAの学習構成。上段は可視パッチからの予測、下段は全画像からの正解生成を表す。同じ色の領域に対応するパッチ表現を比較する。出典:Assran et al.(2023) Figure 3 前処理後の画像を y、パッチ数を N、encoderの特徴次元を d とする。target encoderを f_{\bar\theta} と書くと、正解側の表現は S_y=f_{\bar\theta}(y) =(s_{y,1},\ldots,s_{y,N}), \qquad s_{y,j}\in\mathbb R^d である。\bar\theta はtarget encoderのパラメータを表す。 予測する領域を M 個選び、領域の添字を i\in{1,\ldots,M} とする。図2下段の青・赤・黄の枠が、それぞれ一つの予測対象領域に対応する。i 番目の枠内にあるパッチの番号(添字)を集めた集合を B_i\subseteq{1,\ldots,N} とする。その領域の正解は、S_y から j\in B_i のベクトルを取り出したものである。 ここで順序が重要になる。target側では、先に全画像をencoderへ通し、その後で対象位置の出力を選ぶ。 領域を画像から切り出し、それだけをencoderへ渡す処理ではない。したがって s_{y,j} は、位置 j の画素だけでなく、Self-Attentionを通じた全体の文脈を含みうる。 context側では、前処理後の画像 y のパッチ格子上で、面積比85〜100%の正方形の領域を一つ選ぶ。これはcontext用のマスク領域を決める操作であり、画像を切り出してリサイズする前処理のrandom resized cropとは別である。この領域に含まれるパッチの添字集合を A とすると、実際に見せる集合 C は C=A\setminus\bigcup_{i=1}^{M}B_i である。つまり、どのtargetに含まれるパッチもcontextから除く。target同士は重なってもよいが、予測に用いる可視パッチとtargetは重ならない。 context encoder f_\theta は、この C に残ったパッチだけを処理する。画像全体の黒塗り部分まで計算するのではなく、可視パッチのトークンを入力するため、encoder側の計算を減らせる。元の画像内の位置は位置埋め込みで保持する。 標準的なマスク設定を整理すると次のようになる。6 対象 設定 target数 M 4個 各targetの面積比 画像の15〜20% targetの縦横比 0.75〜1.5 除去前のcontext領域 A 面積比85〜100%、正方形 最終的なcontext C A から全targetとの重複部分を除去 85〜100%は、除去前の領域の面積比である。最終的に画像のほぼ全部を見せるという意味ではない。また、4個のtargetには重なりがあるため、15〜20%を単純に4倍して可視率を求めることもできない。 図3:マスクの具体例。左から原画像、context、4個のtarget。暗い部分はその列で使わない領域を表す。出典:Assran et al.(2023)Figure 4 図3のcontext列では、見える領域が一つの小さな矩形に集中せず、画像内に分布している。I-JEPAの「一つのcontext block」は、大きな矩形から複数のtargetを除いたものであり、最終形状まで一つの穴のない矩形であるとは限らない。 3.2 位置を条件とするパッチ表現の予測 context encoderの出力を S_x、predictorを g_\phi とする。\phi はpredictorのパラメータである。predictorもViTで、contextのパッチ表現と予測位置を指定するmask tokenを、一つのトークン列として処理する。 まず、予測位置を指定するトークンを用意する。i 番目のtargetに含まれる各パッチ位置 j\in B_i に対し、 m_j=m_{\mathrm{mask}}+p_j とする。m_{\mathrm{mask}} は全位置で共有する学習可能なmask vector、p_j は画像内の位置 j を表す位置埋め込みである。枠内のパッチ1個につき1個のトークンを用意し、それぞれの予測位置を指定する。隠れた画素の内容は、このトークンには含まれない。 入力時には、S_x をpredictor用の特徴次元へ射影し、可視パッチの位置埋め込みを加える。そのトークン列の後ろに {m_j}_{j\in B_i} を連結し、全体をTransformerのSelf-Attentionで処理する。これにより、mask tokenはcontextの情報を参照しながら、指定された位置の表現を予測する。 Transformerを通した後は、mask tokenに対応する出力だけを取り出し、教師と同じ特徴次元 d へ射影する。この処理までを含めて g_\phi と書くと、i 番目のtargetの予測は、 \hat S_y^{(i)} =g_\phi\left(S_x\mid{m_j}_{j\in B_i}\right) =\left(\hat s_{y,j}^{(i)}\right)_{j\in B_i} となる。出力の各ベクトル \hat s_{y,j}^{(i)}\in\mathbb R^d が、教師側の同じ位置の表現 s_{y,j} に対応する。7 \mid は位置トークンによる条件付けである。同じ S_x を使っても、与える位置トークンの集合を変えると予測する領域が変わる。位置付きmask tokenをcontext表現と同じトークン列へ入れることで、この条件付けを実現している。 図2のpredictorの左右にある□は、次のように対応する。 図2の位置 対応するトークン・表現 predictor左の白(灰色)の□ context encoderが可視パッチから計算した表現 S_x predictor左の色付き□ target内の各パッチ位置を指定するトークン m_j predictor右の色付き□ 各パッチの予測表現 \hat s_{y,j}^{(i)}。target領域全体では \hat S_y^{(i)} 右の色付き□は予測結果であり、図2下段のtarget encoderが作る正解表現と比較する。青・赤・黄は、それぞれ異なる予測領域を表す。同じcontext表現を使い、各targetの位置トークンを与えてpredictorを適用する。原論文の手順では、M 個のtargetに対してこの処理を M 回行う。 上付きの (i) は本記事で補った記号である。同じ位置 j が複数のtargetに含まれる場合でも、一緒に渡す位置トークンの集合が異なるため、予測をブロックごとに区別している。 学習時には、予測と正解を同じパッチ位置 j で比較し、位置ごとに二乗誤差を計算する。次節では、それらの誤差を足し合わせて一つの損失にする。集約するのは計算済みの誤差であり、予測表現や教師表現をブロック内で平均するわけではない。 predictorの内部特徴次元は標準で384であり、encoderより幅を狭くしている。たとえばViT-H encoderに対しては12層のpredictorを用いる。位置間の関係をSelf-Attentionで処理しつつ、encoderと同じ幅のネットワークを各targetについて動かす費用を避ける設計である。6:1 3.3 表現の回帰とEMAによるtarget encoderの更新 原論文の目的関数は、predictorが予測したパッチ表現と、target encoderが作った正解表現の二乗誤差である。1画像とそのマスクについて、次のように書ける。 \mathcal L(\theta,\phi;\bar\theta) =\frac{1}{M}\sum_{i=1}^{M}\sum_{j\in B_i} \left| \hat s_{y,j}^{(i)}-\operatorname{sg}(s_{y,j}) \right|_2^2. \operatorname{sg} はstop-gradientを表す。値はそのまま使うが、誤差からtarget encoder側へ勾配を流さない、という意味で本記事では明示している。原式に合わせ、ブロック間は 1/M で平均し、ブロック内のパッチについては和を取った。学習では画像とマスクをサンプルし、この損失のバッチ平均から、図2上段のcontext encoderのパラメータ \theta とpredictorのパラメータ \phi を更新する。8 ここには、別画像を負例として遠ざける対照損失も、画素を再構成する損失もない。target encoderが作る正解表現への予測誤差が、context encoderとpredictorを学習する信号になる。 一方、図2下段のtarget encoderのパラメータ \bar\theta は、上段のcontext encoderのパラメータ \theta の指数移動平均(EMA)で更新する。更新回数を t、勾配更新後のcontext encoderのパラメータを \theta_{t+1} とすると、 \bar\theta_{t+1} =\tau_t\bar\theta_t+(1-\tau_t)\theta_{t+1} である。\tau_t は更新前のtarget encoderのパラメータをどれだけ残すかを決める係数で、論文では0.996から1へ線形に増やす。target encoderのパラメータは、context encoderと同じ値で初期化する。6:2 この更新によって、context encoderの重みの変化をtarget encoderがゆっくり追いかける。target encoderは、その移動平均の重みを使って正解表現を作る。 ! 原論文の式は二乗誤差だが、公開実装の src/train.py は、target encoderの出力を特徴次元でLayer Normalizationしたうえで smooth_l1_loss を使っている。本記事の数理的説明は原論文の二乗誤差に基づく。再実装時には、論文の式と公開コードの設定を区別する必要がある。9 事前学習後の評価には、原論文ではtarget encoderを使う。事前学習に [CLS] tokenは使わず、パッチ出力の平均プーリングから画像表現を作る。predictorは事前学習の予測課題を担うものであり、画像分類の出力器として使うものではない。10 4. 意味のある予測を支える条件 4.1 物体の形や配置を捉えるための帰納バイアス I-JEPAの予測課題は、物体の形や部位の配置を手がかりに、隠れた領域の内容を捉える方向へ学習を促すと解釈できる。この帰納バイアスは、表現を予測することに加えて、比較的大きな領域を隠し、その位置を指定してパッチごとの表現を予測させる、という組み合わせから生じる。 隠れた領域を予測するときには、見えている物体のどの部分がどこへ続くのか、指定された位置にはどの部位がありそうか、といった関係が手がかりになる。さらに、正解にはtarget encoderが全画像を見て作った表現を用いる。context側には、全体の文脈を踏まえた、その位置の表現を予測することが求められる。 その際、細かな模様や色まで画素として復元することは目標に含めない。誤差を測るのはtarget encoderの表現なので、そこに残らない画素の違いまで当てる必要はない。物体の構造を捉える情報を予測しつつ、画素の細部を再現する負担を減らすのが、表現空間を予測先にする狙いである。入力の細部を見ないという制約はなく、予測に役立つ細部は利用できる。 この解釈は、原論文の予測表現の可視化とも整合する。別途学習した生成モデルで予測表現を画像化すると、物体の部位や姿勢に対応する構造が現れ、細かな模様や背景はサンプル間で変動する(第5.2節の図4)。11 予測先を表現にする効果は、原論文 Table 7で調べられている。予測対象をtarget encoderの表現にする場合と画素にする場合を比較した実験である。encoderはViT-L/16で、事前学習後に重みを固定し、ImageNetの1%ラベルで線形分類器を学習している。12 予測対象 事前学習epochs 1%ラベルでの線形評価Top-1(%) target encoderの出力表現 500 66.9 画素 800 40.7 この比較では、表現を予測した設定の方が分類情報を線形に読み出しやすくなっている。ただし、事前学習のepoch数は同一ではなく、この表だけで形や配置の情報を直接検証しているわけでもない。形や配置を捉える帰納バイアスという見方は、予測課題の設計と可視化の観察を合わせた解釈である。 次節では、この予測課題を作るうえで、何を隠し、どの手がかりを残すかというマスク設計が持つ効果を見る。 4.2 マスクの大きさと文脈の広がり 教師表現を予測していても、手がかりと正解の組み合わせが悪ければ、認識に役立つ課題にはならない。 小さな欠損なら、近傍の模様の連続性だけで埋められる場合がある。一方、大きく隠しすぎると、見えている領域から正解を推測する手がかりが失われる。I-JEPAは、比較的大きなtargetを複数予測しつつ、画像内の広い範囲にcontextを残すことで、この両側を調整する。 これはマスクの設計意図であり、targetを選ぶ際に物体検出器や物体ラベルを使うわけではない。ランダムに選んだ大きな領域が、必ず一つの物体に対応するわけでもない。 原論文 Table 6は、表現予測を共通にしてマスク戦略を比較している。以下はViT-B/16を300 epochs事前学習し、encoderを固定してImageNetの1%ラベルで線形分類器を学習した結果である。12:1 マスク戦略 予測課題 平均可視率 Top-1精度(%) multi-block 広いcontextから、複数の矩形領域を予測 25% 54.2 rasterized 画像の四分割のうち、一領域から残る三領域を予測 25% 15.5 block 一つの大きな矩形領域を、残りの領域から予測 40% 20.2 random ランダムなパッチ集合を、残りのパッチから予測 40% 17.6 multi-blockとrasterizedは平均可視率が同じでも大きく異なる。さらに、可視率が40%の二方式より、25%のmulti-blockの方が高い。したがって、「見えるパッチを多くすればよい」という説明では足りない。 ただし、この比較ではtargetの個数、形状、配置、面積などが同時に変わる。差のすべてを、contextの空間的な広がりという一要因だけに帰すこともできない。 そこで付録Cの追加実験を見る。同じViT-B/16・300 epochs・1%線形評価で、targetを4個に固定したとき、面積比の範囲を7.5〜20%から15〜20%へ変えると19.2%から54.2%へ改善する。一方、さらに20〜25%へ大きくすると38.9%へ低下する。十分に大きいtargetは重要だが、大きいほどよいわけではない。 targetを広げるとcontextから除かれる領域も変わるため、必要なのは、予測の難しさと手がかりの両立である。13 文脈の使い方は教師側にも現れる。全画像を教師へ通してから出力を選ぶ場合と、各target領域だけを入力する場合を比較すると、ViT-H/16・300 epochs・1%線形評価で、前者が67.3%、後者が56.1%だった(原論文 Table 11)。13:1 これは第3.1節の処理順を裏づける実験である。I-JEPAが予測するのは、単にその場所だけを圧縮したベクトルではなく、全画像の中でその場所を見たときの表現である。予測する側には部分的な観測しか与えず、正解を作る側には全体を見せる。この情報の非対称性も、予測課題の一部になっている。 MSNとの共通点と予測対象の違い Masked Siamese Networks(MSN)14 も、一部のパッチを隠したviewから、マスクしていないviewの表現を学ぶ手法である。画素を再構成せず、可視パッチだけを処理することや、正解側のencoderをEMAで更新することもI-JEPAと共通する。 MSNでは、各viewから画像単位の表現を作り、学習可能なprototype(代表ベクトル)への割当分布が一致するように学習する。cropや色変換、パッチの欠損によって見え方が変わっても、同じ画像に由来するviewには同じ割当を与えようとする。欠損に対して、画像単位の意味表現をそろえる設計である。 I-JEPAでは、contextと位置トークンから、指定された領域内の各パッチの表現を予測する。同じcontextでも、位置を変えれば別の領域が予測対象になる。contextの表現そのものをtargetの表現と一致させることは要求せず、predictorに位置に応じた対応関係を学ばせる。6:3 MSNは欠損に対する画像単位の不変性を学び、I-JEPAは位置に応じた領域間の予測を学ぶ、という違いがある。部分的な観測から表現を学ぶという発想を共有しつつ、何を一致させるかが異なる。この違いは、第4.1節で述べた、局所的な形や配置を捉える帰納バイアスにも関わると解釈できる。 4.3 表現崩壊の回避とpredictorの準最適性 すべての画像で予測も正解も同じ定数ベクトルになれば、損失はゼロになる。損失の式だけを見ると、このような表現崩壊も「正解」に見える。しかし、I-JEPAの一回の更新では、図2下段のtarget encoderが作る正解を固定したまま、上段のcontext encoderとpredictorを学習する。target encoderの重みを動かすのは、その後のEMAである。 この更新を理解する鍵になるのが、BYOL15で論じられた predictorの準最適性(near-optimality) である。準最適とは、今のcontext表現を使ってできる予測を、predictorがほぼできている状態を指す。この見方をI-JEPAへ当てはめて考える。 predictorが十分に学習できていても、context encoderが正解の予測に必要な情報を捨ててしまえば、それをpredictorだけで補うことはできない。target encoderが作る正解は固定されているので、context側が情報を減らすだけでは予測誤差は小さくならない。したがって、さらに誤差を減らすには、context encoderが予測に必要な情報を残す方向へ学習することが求められる。 その後、context encoderが学んだ重みを、EMAでtarget encoderへ少しずつ引き継ぐ。正解側が一度に大きく変わると、predictorは変化に追いつけず、十分に学習できた状態を保ちにくい。EMAは正解をゆっくり変化させ、predictorがその予測に追いつきやすくする役割を持つと考えられる。BYOL原論文はこの解釈を述べ、I-JEPA原論文もEMAによる更新が重要だったと報告している。15:18:1 このように、学習を動的系として捉え、predictorの準最適性を仮定すると、表現崩壊が避けられる理由を説明できる。 条件付き分散と情報保持の導出 ここでは第3.3節の、原論文に記載された二乗誤差を用いる。一つのtarget位置での誤差に注目するが、ブロック内の各パッチの誤差にも同じ分解を適用できる。target encoderのパラメータ \bar\theta は、この計算中は固定する。 教師表現を Z=s_{y,j}\in\mathbb R^d、予測に使える情報を U_\theta=(S_x,B_i,j) とする。U_\theta にはcontext表現と、位置トークンが指定する領域・パッチ位置を含めている。期待値は画像・マスク・予測位置のサンプリングについて取り、\mathbb E[|Z|_2^2]<\infty を仮定する。 その位置に対するpredictorの出力を a_\phi(U_\theta) とし、 R(\theta,\phi) =\mathbb E\left[|Z-a_\phi(U_\theta)|_2^2\right] と置く。計算を短く書くため、A=a_\phi(U_\theta)、\mu=\mathbb E[Z\mid U_\theta] とする。 \begin{aligned} R(\theta,\phi) &=\mathbb E[|Z-A|_2^2]\ &=\mathbb E[|(Z-\mu)+(\mu-A)|_2^2]\ &=\mathbb E[|Z-\mu|_2^2] +\mathbb E[|\mu-A|_2^2]\ &\quad+2\mathbb E[(Z-\mu)^\top(\mu-A)]. \end{aligned} \mu-A は U_\theta の関数なので、交差項は \begin{aligned} \mathbb E[(Z-\mu)^\top(\mu-A)] &=\mathbb E\left[ \mathbb E[Z-\mu\mid U_\theta]^\top(\mu-A) \right]\ &=0 \end{aligned} となる。最後の等号には、\mathbb E[Z-\mu\mid U_\theta]=\mathbb E[Z\mid U_\theta]-\mu=0 を使った。したがって、 R(\theta,\phi) =\mathbb E[|Z-\mu|_2^2] +\mathbb E[|\mu-a_\phi(U_\theta)|_2^2] である。右辺第1項はpredictorの選び方に依存しない。十分な表現能力を持つ理想的なpredictorなら、第2項をゼロにでき、a_\theta^\star(U_\theta)=\mu が最適になる。 この最適予測でも残る誤差を R_\theta^\star とする。k=1,\ldots,d を教師表現の特徴成分の添字とし、ノルムを成分ごとに展開すると、 \begin{aligned} R_\theta^\star &=\mathbb E[|Z-\mu|_2^2]\ &=\mathbb E\left[ \sum_{k=1}^{d}(Z_k-\mu_k)^2 \right]\ &=\mathbb E\left[ \sum_{k=1}^{d}\mathbb E[(Z_k-\mu_k)^2\mid U_\theta] \right]\ &=\mathbb E\left[ \sum_{k=1}^{d}\operatorname{Var}(Z_k\mid U_\theta) \right] \end{aligned} となる。また、 R(\theta,\phi)-R_\theta^\star =\mathbb E\left[ |a_\phi(U_\theta)-a_\theta^\star(U_\theta)|_2^2 \right] \geq 0 が、最適predictorからの超過誤差である。準最適性は、この超過誤差が小さい状態として捉えられる。 次に、contextの情報を捨てた場合を比較する。位置指定は保ったまま、情報を持つ入力 U から、情報を減らした入力 V=h(U) を作るとする。\mu_U=\mathbb E[Z\mid U]、\mu_V=\mathbb E[Z\mid V] と置く。V は U の関数なので、\mu_U-\mu_V も U の関数である。このため、 \begin{aligned} \mathbb E[(Z-\mu_U)^\top(\mu_U-\mu_V)] &=\mathbb E\left[ \mathbb E[Z-\mu_U\mid U]^\top(\mu_U-\mu_V) \right]\ &=0 \end{aligned} となる。この交差項を使って誤差を分解すると、 \begin{aligned} R_V^\star &=\mathbb E[|Z-\mu_V|_2^2]\ &=\mathbb E[|(Z-\mu_U)+(\mu_U-\mu_V)|_2^2]\ &=R_U^\star+\mathbb E[|\mu_U-\mu_V|_2^2]\ &\geq R_U^\star \end{aligned} を得る。入力から情報を捨てるだけでは、最適predictorでも残る誤差を減らせない。 捨てた情報によって教師の条件付き平均が変わる場合、右辺第2項は正になる。これが、予測に使えていた情報を失う方向に抵抗が生じる、という説明の根拠である。予測に関係しない情報を捨てた場合には、第2項がゼロのままでもよい。 この最適値の議論をcontext encoderの更新方向へつなげるには、追加の仮定が必要になる。条件付き平均を実現する最適predictorのパラメータを、\theta の関数 \phi^\star(\theta) として選べるとする。必要な微分可能性、期待値と微分の交換、最適点の一次条件を仮定すると、連鎖律から \begin{aligned} \nabla_\theta R_\theta^\star &=\left.\nabla_\theta R(\theta,\phi)\right|_{\phi=\phi^\star(\theta)}\ &\quad+ \left(\frac{\partial\phi^\star(\theta)}{\partial\theta}\right)^\top \underbrace{\nabla_\phi R(\theta,\phi^\star(\theta))}_{=0}\ &=\left.\nabla_\theta R(\theta,\phi)\right|_{\phi=\phi^\star(\theta)} \end{aligned} となる。\partial\phi^\star(\theta)/\partial\theta は、最適predictorのパラメータがcontext encoderの変化にどう依存するかを表すJacobianである。predictorが最適なので、その変化を介する項は一次条件によって消える。残るのは、predictorのパラメータを固定し、その入力を通してcontext encoderへ逆伝播する勾配である。これが、最適predictorの下ではcontext側の更新を、期待条件付き分散を下げる更新として読める理由になる。 この等式はpredictorが厳密に最適な場合のものである。準最適なpredictorで同じ更新方向を近似すると読むには、近似誤差が勾配も大きく変えないことが必要になる。小さい予測誤差だけで、更新方向の近さや学習全体の安定性まで保証されるわけではない。 また、崩壊した不動点が存在することと、その不動点が周囲の状態を引きつけることは別である。両encoderが既に同じ定数表現になった状態は、不動点として残りうる。BYOL原論文も、最適predictorの下でこうした平衡点が不安定になるという見方を仮説として提示している。ここでは、その情報保持の議論をI-JEPAへ適用した解釈を述べており、ViTやEMAを含む学習全体の安定性を証明したものではない。 BYOLでの導出と更新ダイナミクスについては、BYOLの解説記事でも説明している。 5. 実験結果と評価の限界 5.1 線形評価が示す分類表現の読み出しやすさ I-JEPAは、ImageNet-1Kの線形評価でMAEを上回り、encoderを追加学習しなくても分類情報を取り出しやすい表現を獲得している。 この点を、学習済みencoderの重みを固定し、その出力の上に線形分類器だけを学習する評価で確かめる。 次表は原論文 Table 1から必要な行を抜粋したものだ。事前学習にはImageNet-1Kの画像をラベルなしで使い、線形分類器の学習には全学習ラベルを使う。epoch数は事前学習の回数である。16 手法 encoder 事前学習epochs Top-1精度(%) MAE ViT-H/14 1600 77.2 I-JEPA ViT-H/14 300 79.3 data2vec ViT-L/16 1600 77.3 I-JEPA ViT-L/16 600 77.5 iBOT17 ViT-L/16 250 81.0 I-JEPA ViT-H/16(448解像度) 300 81.1 ViTのB・L・HはそれぞれBase・Large・Hugeを表し、/14 や /16 はパッチの一辺の画素数を表す。本論文のI-JEPAは原則224 × 224で事前学習し、表の最後の行は448 × 448を使う。 中心となるのは、同じViT-H/14を使ったMAEとの比較である。MAEは1600 epochsの事前学習で77.2%、I-JEPAは300 epochsで79.3%だった。encoderを更新しない条件で、I-JEPAは少ない事前学習epochsで2.1ポイント高い精度を得ている。 これは、I-JEPAが事前学習の段階で、分類に必要な違いを線形に取り出しやすい表現を作っていることを支持する。 data2vecとのViT-L/16の比較では、精度は77.3%と77.5%で近いが、事前学習はそれぞれ1600 epochsと600 epochsである。ここでもI-JEPAは、少ない事前学習epochsで同程度の線形評価の精度を得ている。 さらに、高解像度のI-JEPAは81.1%に達し、複数view間の不変性を学ぶiBOTの81.0%に並ぶ。こちらはモデルサイズと解像度が異なる比較だが、I-JEPAでもこの水準の線形分類性能に到達できることを示している。 ラベル数とfine-tuningによる評価条件の違い ラベルを1%に減らした比較では、ViT-H/14のI-JEPAは73.3%、MAEは71.5%だった。これは原論文 Table 2の評価であり、各手法についてfine-tuningまたはlinear probingのよい方を採用している。I-JEPAは50 epochsのfine-tuningを行う。第4章のアブレーションで使った「1%ラベルの線形評価」とは、encoderを更新するかどうかが違う。16:1 全ラベルでfine-tuningすると、448 × 448でI-JEPAのViT-H/16が87.1%、MAEのViT-H/14が87.8%である。事前学習はそれぞれ300、1600 epochsで、パッチサイズも異なる。線形評価での優劣が、そのまま維持されるわけではない。18 線形評価は、事前学習で得た表現から分類情報を取り出しやすいかを調べる。fine-tuningは、ラベルでencoderも調整した後の性能を調べる。両者を分けることで、事前学習した表現から線形に読み出せる情報と、教師ありの追加学習後の分類性能を区別できる。 encoderを固定したまま高い分類性能を得られることが、I-JEPAの表現の有用性を支えている。 次節では、分類情報に加えて、物体数や距離などの情報も読み出せるかを見る。 5.2 分類情報と画像内の構造に関する情報の両立 I-JEPAは、分類情報を読み出しやすい表現を得ると同時に、物体数や距離などの情報も保持している。 encoderを固定した評価と、predictorの予測表現の可視化から、この特徴を見る。 原論文 Table 4では、encoderを固定し、CLEVRの物体数と距離に関する課題を線形に読み出している。CLEVR/Countは物体数の分類、CLEVR/Distは最も近い物体までの距離を区間に分ける分類である。以下は精度(%)の抜粋である。1920 手法 encoder CLEVR/Count CLEVR/Dist MAE ViT-H/14 90.5 72.4 I-JEPA ViT-H/14 86.7 72.4 DINO ViT-B/8 86.6 53.4 iBOT ViT-L/16 85.7 62.8 表の設定では、I-JEPAの距離分類は72.4%で、DINOの53.4%、iBOTの62.8%を上回り、MAEと同じ精度を得ている。第5.1節の結果と合わせると、MAEより高い線形分類精度を得ながら、距離情報もMAEと同じ水準で読み出せている。一方、物体数ではMAEが上回り、DINOとの差も0.1ポイントである。I-JEPAの特徴は、分類情報とこうした構造に関する情報が両方使える点にある。 predictorの予測表現を調べるため、原論文ではRCDM(Representation-Conditioned Diffusion Model)21 による可視化も行っている。これは、別途学習した拡散decoderに、predictorの出力を条件として与えて画像を生成する方法である。11:1 図4:predictorの出力表現の可視化。左右それぞれの例で、左から元画像、context、4個の生成サンプルを並べている。緑枠が予測対象領域。出典:Assran et al.(2023)Figure 6 鳥の例では背中に対応する領域に鳥の身体らしい構造が、車の例では車体の上部に対応する構造が現れる。一方、細部の模様や色合いはサンプル間で変動する。共通して現れる部位や姿勢は、predictorがその位置の物体構造を捉えた表現を予測していることを示唆する。 この結果は、第4.1節で述べた、物体の形や配置を捉えながら、画素の細部を再現する負担を減らすという設計意図を支持する観察である。 可視化の手順と読み取りの範囲 まず、学習済みのcontext encoderとpredictorを固定する。次に、predictorのパッチ出力を平均プーリングした表現を条件として、画像へ戻す拡散decoderを別途学習する。図4の緑枠内は、そのdecoderへ異なる乱数を与えて得たサンプルである。可視化の対象は、target encoderそのものではなくpredictorの予測表現である。 このdecoderはI-JEPAの事前学習には含まれない。また、表示される画像にはdecoderの能力や事前知識も反映される。サンプル間で変わる情報が元の表現に存在しないと証明したものではなく、予測表現に残る構造を探るための定性的な手がかりとして読む。 5.3 反復回数の削減による事前学習の効率化 I-JEPAは、有用な表現を得るまでの反復回数を減らすことで、事前学習全体の計算コストを抑えている。 target encoderが全画像を処理する追加コストがあるため、原論文のMAEとの比較では、1 iterationあたりの処理時間は約7%長い。一方、必要なiterationは約5分の1だった。一回の処理にかかる追加コストより、必要な反復回数が減る効果の方が大きく、総学習コストが下がっている。22 構成上も、context encoderには可視パッチだけを渡し、predictorには幅の狭いViTを使う。複数viewを処理する手法に対しては、target encoderが一つのviewだけを処理すればよい点も効率につながる。論文では、ImageNet上のViT-H/14を16台のA100で72時間未満で事前学習できたと報告している。22:1 第5.1節の、少ない事前学習epochsで高い線形分類精度を得る結果と合わせると、何を予測させるかという課題の設計は、有用な表現を得るまでの計算コストにも関わることが分かる。 計算量の比較条件と適用範囲 学習時間は、論文で使われたモデル・データ・実装における結果である。複数viewを使う手法では、1画像あたりに処理する入力の数も異なる。epoch数、1 stepの処理時間、GPU台数を掛けたGPU-hours、実時間は、それぞれ区別して比較する必要がある。 また、計算効率の改善が、すべてのタスクでの優位性を意味するわけではない。転移分類のiNaturalist18では、線形評価がI-JEPAのViT-H/14で47.6%、DINOのViT-B/8で55.9%、iBOTのViT-L/16で57.3%だった。16:2 I-JEPAは、異なる領域の間に予測に使える依存関係があることを利用する。たとえば、周囲から予測しにくい微小な異常が重要な課題では、大きなブロックの予測と抽象化が適切かどうかを別途評価する必要がある。これは本論文が実験した失敗例ではなく、予測課題と下流で必要な情報の違いから生じる検討事項である。 本論文の対象は静止画像内の領域間の関係であり、時間的な変化、行動の結果、因果介入を学習・評価したものではない。 6. さいごに I-JEPAから持ち帰りたいのは、データの構造を手がかりに、下流で必要な情報を学べる予測課題(pretext task)を考えるという視点である。 I-JEPAの設計が促すのは、物体の形や配置を捉える抽象化だと解釈できる。見えている胴体から隠れた頭の表現を予測するときには、部位ごとの違いを保ちながら、そのつながりや配置を捉えることが手がかりになる。正解を表現空間に置くことで、そこに含まれない画素の細部を再現する負担も減らせる。予測する対象と与える文脈を選ぶことが、こうした抽象化を促す帰納バイアスになる。 この個性は、事前学習済みcheckpointを選ぶときの参考にもなる。第5章で見た分類情報と物体数・距離の情報の両立は、I-JEPAを選ぶ理由の一つになりうる。何を予測する課題で学んだかを知ることは、その表現が自分の用途に合うかを考える手がかりになる。 自分で事前学習を行う場合には、この対応を予測課題の設計に反映することになる。I-JEPAが自然画像にある部位のつながりや配置を利用したように、別のドメインでも、そのデータの成り立ちや構造から予測に使える関係を考えたい。その関係を学ぶことで下流に必要な情報が残るよう、データの背景に合ったpretext taskを設計することが大切である。 脚注 Mathilde Caron et al., Emerging Properties in Self-Supervised Vision Transformers, ICCV 2021。DINOは、異なるviewに対する自己蒸留を用いる。 ↩︎ Kaiming He et al., Masked Autoencoders Are Scalable Vision Learners, CVPR 2022。 ↩︎ Alexei Baevski et al., data2vec: A General Framework for Self-supervised Learning in Speech, Vision and Language, ICML 2022。全入力から得る文脈を含む教師表現を、マスクされた入力から予測する。 ↩︎ 公式実装の画像変換とViT-H/14の標準設定。この設定ではcolor distortion、Gaussian blur、horizontal flipは無効だが、random resized cropは用いる。 ↩︎ Alexey Dosovitskiy et al., An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale, ICLR 2021。 ↩︎ I-JEPA原論文第3節および付録A.1。ミニバッチ処理では、同じGPU上で扱うcontextのパッチ数、targetのパッチ数をそれぞれ揃える処理も行う。 ↩︎ ↩︎ ↩︎ ↩︎ 公式実装のVisionTransformerPredictor。forwardでトークンの連結、Transformerによる処理、予測出力の取り出しを確認できる。 ↩︎ I-JEPA原論文第3節「Loss」。二乗ノルムをブロック内で加算する原式と、教師をEMAで更新する手順に基づく。 ↩︎ ↩︎ 公式実装のsrc/train.py。forward_targetでは勾配計算を止めた教師出力にlayer_normを適用し、loss_fnではsmooth_l1_lossを使う。 ↩︎ I-JEPA原論文付録A。下流の線形評価では、最終層の平均プーリングだけでなく、最後の4層をそれぞれ平均プーリングして連結した表現や、分類head前のBatch Normalizationの有無も比較し、よい設定を採用する。 ↩︎ I-JEPA原論文第8節および付録E。predictor出力を平均プーリングし、別途学習したRCDM decoderの条件として可視化する。 ↩︎ ↩︎ I-JEPA原論文第9節とTables 6–7。両実験とも1%ラベルの線形評価だが、マスク戦略はViT-B/16、予測空間はViT-L/16の実験である。 ↩︎ ↩︎ I-JEPA原論文付録C、Tables 8–11。target面積、context面積、target個数、教師へのマスク位置を比較している。 ↩︎ ↩︎ Mahmoud Assran et al., Masked Siamese Networks for Label-Efficient Learning, ECCV 2022。第3節に、viewのマスク、prototypeへの割当分布の整合、target encoderのEMA更新を記載。 ↩︎ Jean-Bastien Grill et al., Bootstrap Your Own Latent: A New Approach to Self-Supervised Learning, NeurIPS 2020。第3.2節では最適predictorと条件付き分散から崩壊点の不安定性を仮説として説明し、付録Hで更新方向との関係、付録Iで準最適性に関する実験を示す。 ↩︎ ↩︎ I-JEPA原論文第5節、Tables 1–3および付録A.2。Table 1は全ラベルの線形評価、Table 2は1%ラベルで各手法に適した適応方法を採用した比較、Table 3は転移先での線形評価である。 ↩︎ ↩︎ ↩︎ Jinghao Zhou et al., iBOT: Image BERT Pre-Training with Online Tokenizer, ICLR 2022。異なるview間の自己蒸留とパッチ単位の学習を組み合わせる。 ↩︎ I-JEPA原論文付録D、Table 15。I-JEPAでは全ImageNetラベルで50 epochsのfine-tuningを実施する。 ↩︎ I-JEPA原論文第6節、Table 4。評価時の入力変換も付録A.2に記載され、CLEVRでは物体を除いてしまうrandom cropを避けている。 ↩︎ VTABの公式CLEVR実装では、Countは3〜10個の8クラス、Distは最近傍物体の距離を6区間に分けた分類として定義されている。 ↩︎ Florian Bordes, Randall Balestriero, Pascal Vincent, High Fidelity Visualization of What Your Self-Supervised Representation Knows About, TMLR 2022。 ↩︎ I-JEPA原論文第7節およびAbstract。所要時間は論文の実験報告であり、本記事で事前学習を追試した結果ではない。 ↩︎ ↩︎
Google's harness2 Rewrites AI Agent Setup A short look at google-research/harness2, an open source research project that rewrites the setup around a fixed AI model so it does better on work tasks. Code and docs: google-research/harness2
ゼロショットで予測できる TimesFM 3.0 を BigQuery で動かしてみた はじめに BigQuery で TimesFM 3.0 が使えるという紹介ブログを見て、少し興味を持ちました。ゼロショットで予測できるモデルっぽいから、使えるなら使ってみたいかなあ、くらいの気持ちです。 そこで、BigQuery の公開データを使って TimesFM 3.0 を動かし、前のバージョンの 2.5 と比べたり、祝日や天気を渡したりしてみました。自分の備忘録も兼ねて、分かったことをまとめます。 TL;DR TimesFM 3.0 とは 日付順の数字から、この先の値と予測区間を返す 自分のデータで学習させずに使える(ゼロショット) どう書くか AI.FORECAST 関数に表を渡す 予測の手がかりになる列(共変量)は future_covariate_cols などで指定する 2.5→3.0 で何が変わったのか 共変量と多変量を扱えるようになった 1 つの系列の予測は、今回の条件では 2.5 のほうが精度が少し高かった 天気を渡すと予測は動いたが、今回の条件では精度はほぼ変わらなかった プロジェクトにどう組み込むか 3.0 を商用で使えるのは BigQuery だけだが、まだ Preview 本番に入れるなら GA 待ちで、1 つの系列の予測なら BigQuery で 2.5 を今から使える TimesFM 3.0 とは TimesFM は、Google Research が作った時系列予測のモデルです。売上やアクセス数のような、日付順に並んだ数字(系列)を渡すと、この先の値と、その幅(予測区間)を返します。 Google が大量の時系列データで事前に学習させてあるので、自分のデータで学習させずに(ゼロショットで)使えます。ただ、予測のたびに過去の系列を渡す必要があり、天気や祝日のような表にない情報は、渡さない限り考慮されません。 BigQuery にもともとある予測モデル ARIMA_PLUS と並べると、こうなります。 ARIMA_PLUS TimesFM 使う前の準備 CREATE MODEL で自分のデータを学習させる なし 予測のときに渡すもの 予測する期間 過去の系列(必要なら、天気などの共変量も) 自分のデータの扱い 学習でモデルに覚えさせる 予測のたびに読むだけで、覚えない BigQuery では 2.5 と 3.0 を選べます。 どう書くか(AI.FORECAST) AI.FORECAST の引数の一覧は、AI.FORECAST Syntax にあります。 書き方は 2 通りあり、共変量を渡せるのは Multivariate だけです。 1 つの系列を予測する 共変量を渡す リファレンスの節 Univariate Multivariate 予測する列 data_col(1 列) target_cols(リスト。複数書くと、複数の列を同時に予測する多変量になる) 共変量 渡せない future_covariate_cols、past_covariate_cols model 2.5 も 3.0 も選べる(省くと 2.5) 'TimesFM 3.0' を必ず書く また、今回使ったのは、次の BigQuery の公開データです。 bigquery-public-data.new_york.citibike_trips:Citi Bike(ニューヨークのシェアサイクル)の利用記録 1 回の利用が 1 行で、2013-07-01〜2016-09-30 のデータがある bigquery-public-data.noaa_gsod:NOAA(米国海洋大気庁)の気象データ 観測所ごと・日ごとの天気 例はどちらも、実績が分かっている 2016 年 9 月を隠して予測しています。精度は「2.5→3.0 で何が変わったのか」で見ます。 1 つの系列を予測する 今回は、8 月末までの利用回数から、9 月の利用回数を予測しました。Citi Bike の利用記録を日ごとの利用回数(daily)にまとめ、AI.FORECAST に渡しました。 SELECT * FROM AI.FORECAST( TABLE daily, -- 予測に使う過去のデータ(1日1行) data_col => 'num_trips', -- 予測する列 timestamp_col => 'dt', -- 日付の列 model => 'TimesFM 3.0', -- 省くと TimesFM 2.5 horizon => 30 -- 何点先まで予測するか(1日1行なので日数) ); SQL 詳細WITH daily AS ( -- 日ごとの利用回数(8/31 まで) SELECT DATE(starttime) AS dt, COUNT(*) AS num_trips FROM bigquery-public-data.new_york.citibike_trips WHERE starttime IS NOT NULL AND starttime < '2016-09-01' -- 9月は答え合わせに使うので隠す GROUP BY dt ) SELECT * FROM AI.FORECAST( TABLE daily, data_col => 'num_trips', timestamp_col => 'dt', model => 'TimesFM 3.0', horizon => 30 ); 実行結果(抜粋) forecast_timestamp forecast_value confidence_level prediction_interval_lower_bound prediction_interval_upper_bound ai_forecast_status 2016-09-01 00:00:00 56191.88 0.95 49976.22 61698.79 '' 2016-09-02 00:00:00 53535.74 0.95 44921.19 60750.70 '' 2016-09-03 00:00:00 42717.00 0.95 32562.72 51680.64 '' 2016-09-04 00:00:00 40867.40 0.95 28283.59 51935.86 '' forecast_value が予測の中央値、prediction_interval_lower_bound と prediction_interval_upper_bound が 95%(confidence_level)の予測区間です。ai_forecast_status は、成功なら空文字です(値は小数 2 桁に丸めています)。 共変量を渡す 共変量は、予測の手がかりとして一緒に渡す列です。今回は、天気(降水量・平均気温)を共変量にして、9 月の利用回数を予測しました。 日ごとの利用回数(num_trips)に、セントラルパークの降水量(prcp)と平均気温(temp)の列を足した表(daily_with_weather)を作り、AI.FORECAST に渡しました。daily_with_weather の 8 月末から 9 月初めの行は、次のとおりです。 dt num_trips prcp(インチ) temp(華氏) 2016-08-30 57,405 0.0 77.0 2016-08-31 57,214 0.0 78.9 2016-09-01 NULL 0.04 73.9 2016-09-02 NULL 0.46 72.6 SELECT * FROM AI.FORECAST( TABLE daily_with_weather, -- 過去の行と、利用回数を NULL にした予測する日の行 target_cols => ['num_trips'], -- 予測する列 timestamp_col => 'dt', -- 日付の列 future_covariate_cols => ['prcp', 'temp'], -- 予測する期間の値も分かっている列 model => 'TimesFM 3.0', -- 共変量を渡すときは 3.0 が必須 horizon => 30, -- 何点先まで予測するか(9月の30日間) context_window => 1024 -- 予測に使う過去の点数 ); ちなみに、今日までの値しか分からない情報には、future_covariate_cols ではなく past_covariate_cols を使います。 SQL 詳細WITH daily AS ( -- 日ごとの利用回数 SELECT DATE(starttime) AS dt, COUNT(*) AS num_trips FROM bigquery-public-data.new_york.citibike_trips WHERE starttime IS NOT NULL GROUP BY dt ), weather AS ( -- セントラルパークの観測所の日ごとの天気 SELECT DATE(CAST(year AS INT64), CAST(mo AS INT64), CAST(da AS INT64)) AS dt, IF(prcp = 99.99, 0.0, prcp) AS prcp, -- 降水量(インチ)。欠測は 0 とみなす temp -- 平均気温(華氏) FROM bigquery-public-data.noaa_gsod.gsod* WHERE _TABLE_SUFFIX BETWEEN '2013' AND '2016' AND stn = '725053' AND wban = '94728' ), daily_with_weather AS ( -- 利用回数に天気を添えた入力(9/30 まで) SELECT d.dt, IF(d.dt < DATE '2016-09-01', d.num_trips, NULL) AS num_trips, -- 予測する9月は NULL にする w.prcp, w.temp FROM daily AS d JOIN weather AS w ON w.dt = d.dt ) SELECT * FROM AI.FORECAST( TABLE daily_with_weather, target_cols => ['num_trips'], timestamp_col => 'dt', future_covariate_cols => ['prcp', 'temp'], model => 'TimesFM 3.0', horizon => 30, context_window => 1024 ); 実行結果(抜粋) dt num_trips.value num_trips.prediction_interval_lower_bound num_trips.prediction_interval_upper_bound time_series_type confidence_level ai_forecast_status 2016-09-01 00:00:00 53410.43 45873.04 59963.85 forecast 0.95 '' 2016-09-02 00:00:00 49312.29 39320.58 58367.76 forecast 0.95 '' 2016-09-03 00:00:00 43553.52 31200.80 54973.15 forecast 0.95 '' 2016-09-04 00:00:00 44209.27 30954.60 56406.93 forecast 0.95 '' num_trips は 1 つの列で、中に予測の中央値(value)と予測区間の上下限が入っています。表では、取り出すときの書き方に合わせて num_trips.value のように分けて示しました。日付の列は、1 つの系列では forecast_timestamp になりますが、こちらは timestamp_col に渡した dt のまま返ります(値は小数 2 桁に丸めています)。 注意点 future_covariate_cols を使うときは、リファレンスでは context_window(予測に使う過去の点数)を「表の行数 − horizon」以下にするとされています 今回は日ごとの表が 1,200 行弱、horizon が 30 3.0 の既定の 2,048 ではこの決まりを満たさないので 1,024 に設定 context_window は、3.0 では 32 の倍数(64〜2,048)です horizon の上限は 2.5 が 10,000、3.0 が 1,024 です target_cols を使うと、出力は予測する列ごとの STRUCT になり、予測値は num_trips.value で取り出します 費用は SQL 1 本あたり約 250MB〜1GB の読み込みです。2026-12-01 からは、3.0 にトークン課金が加わります(AI.FORECAST Pricing)。 2.5→3.0 で何が変わったのか 精度は上がるのか 「1 つの系列を予測する」の SQL で、2016 年 9 月を 2.5 と 3.0 に予測させ、実績と比べました。精度は、実績とのずれで見ます。ずれは「予測と実績の差の絶対値 ÷ 実績」で算出し、値が小さいほど精度が高いことになります。 2016 年 9 月の実績と予測(8 月までのデータで予測) 2 本の予測はほとんど重なりました。急に落ちた日(9/1、9/19、9/30)はいずれも平日で、どちらのモデルもずれが大きくなっています。ずれを平均すると、次のとおりです。 2.5 3.0 30 日間の平均 18.4% 19.9% 平日(22 日)の平均 20.3% 20.7% 土日(8 日)の平均 13.1% 17.7% 紹介ブログでは、3.0 は共変量なしでも 2.5 を含む他のモデルと同等以上とされていますが、これは多くのデータセットでの平均順位として書かれています。今回比べたのは 1 つのデータの 1 か月分で、言えるのは、このデータの 9 月では 2.5 のほうが精度が少し高かった、というところまでです。 2.5 と 3.0 の予測を実績と並べる SQLWITH daily AS ( -- 日ごとの利用回数 SELECT DATE(starttime) AS dt, COUNT(*) AS num_trips FROM bigquery-public-data.new_york.citibike_trips WHERE starttime IS NOT NULL GROUP BY dt ), train AS ( SELECT * FROM daily WHERE dt < '2016-09-01' -- 9月を隠す ), fc_25 AS ( SELECT * FROM AI.FORECAST(TABLE train, data_col => 'num_trips', timestamp_col => 'dt', model => 'TimesFM 2.5', horizon => 30) ), fc_30 AS ( SELECT * FROM AI.FORECAST(TABLE train, data_col => 'num_trips', timestamp_col => 'dt', model => 'TimesFM 3.0', horizon => 30) ) SELECT a.dt, a.num_trips AS actual, ROUND(f25.forecast_value) AS forecast_25, -- forecast_value は予測の中央値 ROUND(f30.forecast_value) AS forecast_30, -- その日の予測が実績から何%ずれたか ROUND(ABS(f25.forecast_value - a.num_trips) / a.num_trips * 100, 1) AS error_pct_25, ROUND(ABS(f30.forecast_value - a.num_trips) / a.num_trips * 100, 1) AS error_pct_30, -- 30日分の平均を全行に入れる(OVER () は行をまとめずに全体で集計する) ROUND(AVG(ABS(f25.forecast_value - a.num_trips) / a.num_trips) OVER () * 100, 1) AS avg_error_pct_25, ROUND(AVG(ABS(f30.forecast_value - a.num_trips) / a.num_trips) OVER () * 100, 1) AS avg_error_pct_30 FROM daily AS a -- 予測の日付は時刻付きで返るので、日付に直して実績とつなぐ JOIN fc_25 AS f25 ON DATE(f25.forecast_timestamp) = a.dt JOIN fc_30 AS f30 ON DATE(f30.forecast_timestamp) = a.dt ORDER BY a.dt; 共変量を渡せるようになった BigQuery で共変量と多変量を扱えるのは、「どう書くか」の表のとおり 3.0 だけです。紹介ブログには、アイスクリームの販促予定を渡すと、販促日ごとに売上が約 2 割増える予測になる、という例があります。公開データでも同じような結果になるかを、祝日と天気を渡して試しました。ここからは、予測がどれだけ動いたかを回数で、精度をずれ(%)で見ます。 祝日を渡してみる 会員の利用回数で 2016 年 7 月の前半(7/1〜14)を隠し、その日が独立記念日か(1 か 0)を渡す場合と渡さない場合を比べました。2016 年の独立記念日は 7/4 です。 2016 年 7 月の会員の利用回数と、独立記念日を渡す・渡さない予測(TimesFM 3.0) 7/4 の実績は 22,349 回で、普段の平日の半分以下でした。予測は、独立記念日を渡さないと 46,555 回、渡しても 42,234 回で、どちらも実績の 2 倍前後のままでした。参照した過去 1,024 日に独立記念日は 2 回しかなく、例が少なかったのだと考えています。 ! SQL は会員と非会員を同時に予測しています(多変量)が、グラフと数字は会員のみの結果です。 独立記念日を渡す・渡さない予測を実績と並べる SQLWITH daily AS ( -- 日ごとの会員・非会員の利用回数(7/14 まで) SELECT DATE(starttime) AS dt, COUNTIF(usertype = 'Subscriber') AS subscriber_trips, -- 会員の利用回数 COUNTIF(usertype = 'Customer') AS casual_trips -- 非会員の利用回数 FROM bigquery-public-data.new_york.citibike_trips WHERE starttime IS NOT NULL AND starttime < '2016-07-15' GROUP BY dt ), holidays AS ( -- 独立記念日(土曜は前日の金曜に振り替えた日) SELECT dt FROM UNNEST([ -- 並べた日付を1行ずつの表にする DATE '2013-07-04', DATE '2014-07-04', DATE '2015-07-03', DATE '2016-07-04' ]) AS dt ), with_flag AS ( -- 祝日を渡す側の入力(7/14 までの全行を持つ) SELECT d.dt, -- 予測したい日(7/1 以降)は NULL にする IF(d.dt < DATE '2016-07-01', d.subscriber_trips, NULL) AS subscriber_trips, IF(d.dt < DATE '2016-07-01', d.casual_trips, NULL) AS casual_trips, -- 祝日は先の日付でも分かるので全期間に値を入れる IF(h.dt IS NULL, 0.0, 1.0) AS is_holiday FROM daily AS d LEFT JOIN holidays AS h ON d.dt = h.dt -- 祝日でない日は h.dt が NULL になる ), without_flag AS ( -- 祝日を渡さない側の入力(6/30 まで、祝日の列はない) SELECT dt, subscriber_trips, casual_trips FROM daily WHERE dt < DATE '2016-07-01' ), fc_with AS ( SELECT * FROM AI.FORECAST( TABLE with_flag, target_cols => ['subscriber_trips', 'casual_trips'], -- 同時に予測する列 timestamp_col => 'dt', future_covariate_cols => ['is_holiday'], -- 予測する期間の値も分かっている列 model => 'TimesFM 3.0', horizon => 14, context_window => 1024 -- 予測に使う過去の点数 ) ), fc_without AS ( SELECT * FROM AI.FORECAST( TABLE without_flag, target_cols => ['subscriber_trips', 'casual_trips'], timestamp_col => 'dt', model => 'TimesFM 3.0', horizon => 14, context_window => 1024 ) ) SELECT a.dt, f.is_holiday, a.subscriber_trips AS actual_subscriber, ROUND(fo.subscriber_trips.value) AS without_flag_subscriber, -- 予測値と区間の上下限をまとめた列から予測値を取り出す ROUND(fw.subscriber_trips.value) AS with_flag_subscriber, a.casual_trips AS actual_casual, ROUND(fo.casual_trips.value) AS without_flag_casual, ROUND(fw.casual_trips.value) AS with_flag_casual FROM daily AS a JOIN with_flag AS f ON f.dt = a.dt JOIN fc_without AS fo ON DATE(fo.dt) = a.dt JOIN fc_with AS fw ON DATE(fw.dt) = a.dt WHERE fo.time_series_type = 'forecast' AND fw.time_series_type = 'forecast' -- 予測の行だけに絞る ORDER BY a.dt; 天気を渡してみる 次は、9 月を隠し、天気(降水量・平均気温)を渡す場合と渡さない場合を比べました。天気を渡す予測は、「共変量を渡す」と同じ SQL で作り、それに天気を渡さない予測と、実績との比較を足しました。渡したのは実際の天気なので、天気予報が完全に当たった場合と同じ条件です。実際に使うなら、渡すのは予報です。 2016 年 9 月の実績と、天気を渡す・渡さない予測(TimesFM 3.0) 天気を渡すと、予測は 1 日平均約 2,400 回動きました。ただ、平均のずれは渡さない場合とほぼ同じで、精度は変わりませんでした。予測が動いても実績に近づかなかった要因は、主に次の 2 つです。 雨の日(降水量 0.2 インチ以上)は 8 日あり、そのうち 7 日で予測が下がりました。しかし、実績が渡さない予測より少なかったのは 2 日だけで、残りは雨でも思ったほど減っていませんでした 月末(9/24〜29)は、天気を渡した予測がさらに下がり、ずれが大きくなりました 天気を渡す・渡さない予測を実績と並べる SQLWITH daily AS ( -- 日ごとの利用回数 SELECT DATE(starttime) AS dt, COUNT(*) AS num_trips FROM bigquery-public-data.new_york.citibike_trips WHERE starttime IS NOT NULL GROUP BY dt ), weather AS ( -- セントラルパークの観測所の日ごとの天気 SELECT DATE(CAST(year AS INT64), CAST(mo AS INT64), CAST(da AS INT64)) AS dt, IF(prcp = 99.99, 0.0, prcp) AS prcp, -- 降水量(インチ)。欠測は 0 とみなす temp -- 平均気温(華氏) FROM bigquery-public-data.noaa_gsod.gsod* WHERE _TABLE_SUFFIX BETWEEN '2013' AND '2016' AND stn = '725053' AND wban = '94728' ), daily_with_weather AS ( -- 天気を渡す側の入力(9/30 までの全行を持つ) SELECT d.dt, IF(d.dt < DATE '2016-09-01', d.num_trips, NULL) AS num_trips, -- 9月は NULL にして隠す w.prcp, w.temp FROM daily AS d JOIN weather AS w ON w.dt = d.dt ), daily_without_weather AS ( -- 天気を渡さない側の入力(8/31 まで) SELECT dt, num_trips FROM daily WHERE dt < DATE '2016-09-01' ), fc_with AS ( SELECT * FROM AI.FORECAST( TABLE daily_with_weather, target_cols => ['num_trips'], timestamp_col => 'dt', future_covariate_cols => ['prcp', 'temp'], -- 予測する期間の値も分かっている列 model => 'TimesFM 3.0', horizon => 30, context_window => 1024 ) ), fc_without AS ( SELECT * FROM AI.FORECAST( TABLE daily_without_weather, target_cols => ['num_trips'], timestamp_col => 'dt', model => 'TimesFM 3.0', horizon => 30, context_window => 1024 ) ) SELECT a.dt, f.prcp, f.temp, a.num_trips AS actual, ROUND(fo.num_trips.value) AS forecast_without_weather, ROUND(fw.num_trips.value) AS forecast_with_weather, ROUND(ABS(fo.num_trips.value - a.num_trips) / a.num_trips * 100, 1) AS error_pct_without, ROUND(ABS(fw.num_trips.value - a.num_trips) / a.num_trips * 100, 1) AS error_pct_with, ROUND(AVG(ABS(fo.num_trips.value - a.num_trips) / a.num_trips) OVER () * 100, 1) AS avg_error_pct_without, ROUND(AVG(ABS(fw.num_trips.value - a.num_trips) / a.num_trips) OVER () * 100, 1) AS avg_error_pct_with FROM daily AS a JOIN daily_with_weather AS f ON f.dt = a.dt JOIN fc_without AS fo ON DATE(fo.dt) = a.dt JOIN fc_with AS fw ON DATE(fw.dt) = a.dt WHERE fo.time_series_type = 'forecast' AND fw.time_series_type = 'forecast' ORDER BY a.dt; 結果として、例が数回しかない祝日は予測をあまり動かさず、毎日ある天気は大きく動かしました。ただ、予測が動くことと精度が上がることは別でした。 プロジェクトにどう組み込むか TimesFM は BigQuery のほかでも動かせます(2026-10-19 時点)。 経路 選べるバージョン 3.0 の扱い BigQuery(AI.FORECAST) 2.5、3.0 Google Cloud の利用規約に従い、商用で使える。ただし Preview Model Garden 2.0、2.5(自分でエンドポイントにデプロイする) 3.0 はない Hugging Face(重みを自前で動かす) 2.5(Apache-2.0)、3.0 3.0 の重みは非商用・非本番に限られる ライセンス上、3.0 を商用で使えるのは BigQuery だけです(GitHub の README、BigQuery の TimesFM のページ)。ただし BigQuery の 3.0 も Preview で、原則テスト環境向けなので、本番に入れるなら GA 待ちです。1 つの系列の予測で足りるなら、Preview ではない 2.5 を今から使えます。 どちらのバージョンでも、アプリからは、SQL で予測した結果を表に書き込み、それを読む形になります。 おわりに 今回試していないことを残しておきます。 実際の天気ではなく天気予報を渡した場合 別の月でも答え合わせをすると、2.5 と 3.0 の差はどうなるか 多変量で同時に予測すると、別々に予測するより精度が上がるか 共変量と多変量は、今回の執筆で初めて知った言葉でした。実際に試しながら意味を確かめられたので、勉強になりました。共変量は、過去に何度も出てくる情報ほど、予測を動かしやすいのかもしれません。 参考 TimesFM-3: A zero-shot foundation model for multivariate forecasting The AI.FORECAST function The TimesFM model google-research/timesfm Google Cloud のプロダクトのリリース段階 採用 「あらゆる判断を、Data-Informedに。」 株式会社ギックスでは、一緒に挑戦してくれる仲間を募集しています! 株式会社ギックス HP 株式会社ギックス 採用サイト エンジニア求人情報