Oolong Benchmark (#323)

* (feat) Add Oolong Benchmarks

* (fix) Address issues to fix basedpyright and coderabbit comments

* (fix) Address basedpyrwright additional warnings

* (fix) Address additional coderabbit issues

* (fix) Replace huggingface data loading to local filesystem-based

* (fix) Address coderabbit issues regarding data paths

* fix: Align with test harness conventions

* fix: Code Review Comments

* fix: stream data rather than load all at once

---------

Co-authored-by: 3un01a <3un01a@plasticlabs.ai>
Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
This commit is contained in:
3un01a 2026-02-24 06:55:59 +09:00 committed by GitHub
parent 780bfe1c30
commit eba9279af2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1447 additions and 7 deletions

View File

@ -34,6 +34,7 @@ dependencies = [
"json-repair>=0.49.0",
"turbopuffer>=1.8.1",
"lancedb>=0.25.3",
"pyarrow>=19.0.0",
"redis>=7.0.0,<8.0.0",
"cashews[redis]==7.4.4",
"scikit-learn>=1.6.0",

View File

@ -108,6 +108,7 @@ def main():
"run",
"pytest",
"tests/alembic/test_pipeline.py",
"-n0",
]
elif revision_ids:
# Build a -k expression to filter tests by revision ID
@ -125,6 +126,7 @@ def main():
"run",
"pytest",
"tests/alembic/test_pipeline.py",
"-n0",
"-k",
k_expression,
]

View File

@ -4,3 +4,4 @@ perf_metrics
beam_data
obexeval_data
locomo_data
oolongeval_data

View File

@ -6,6 +6,64 @@ This directory contains benchmarking tools for evaluating Honcho's long-term mem
- **LongMemEval**: Tests memory retention across multi-session conversations
- **BEAM**: Beyond a Million Tokens - comprehensive long-term memory evaluation across 10 memory abilities
- **LoCoMo**: Long conversation memory benchmark across multi-hop and temporal questions
- **OOLONG**: Long-context aggregation benchmark with `synth` and `real` variants
## Benchmark Workflow
Use a harness-first workflow for all benchmark runs:
1. Start Honcho locally with the benchmark harness:
```bash
python tests/bench/harness.py
```
2. Run one of the benchmark runners in another terminal:
```bash
# LongMemEval
python -m tests.bench.longmem --test-file tests/bench/longmemeval_data/longmemeval_oracle.json
# LoCoMo
python -m tests.bench.locomo --data-file tests/bench/locomo_data/locomo10.json
# BEAM
python -m tests.bench.beam --context-length 100K
```
3. For OOLONG, point `--data-dir` at your local dataset clone:
```bash
# OOLONG-synth
python -m tests.bench.oolong --variant synth --data-dir /path/to/oolong-synth
# OOLONG-real
python -m tests.bench.oolong --variant real --data-dir /path/to/oolong-real
# OOLONG-synth with label-augmented context (upstream optional mode)
python -m tests.bench.oolong --variant synth --data-dir /path/to/oolong-synth --labels
```
Notes for OOLONG runs:
- By default, synth uses `context_window_text` (upstream baseline behavior).
- Use `--labels` to switch synth ingestion to `context_window_text_with_labels`.
- Default `--min-context-len` is `1024` and filtering uses strict `>` matching upstream.
Expected local dataset layout:
```text
oolong-synth/
data/
test-*.parquet
validation-*.parquet
oolong-real/
dnd/
test.jsonl
validation.jsonl
```
## Development Harness

603
tests/bench/oolong.py Normal file
View File

@ -0,0 +1,603 @@
"""
Honcho OOLONG benchmark runner.
Evaluates long-context reasoning and aggregation on:
- OOLONG-synth: synthetic ICL aggregation tasks
- OOLONG-real: D&D transcript aggregation tasks
"""
import argparse
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, TypedDict, cast
from dotenv import load_dotenv
from honcho.api_types import MessageCreateParams
from honcho.session import Session, SessionPeerConfig
from src.config import settings
from .oolong_common import (
calculate_context_length,
calculate_task_statistics,
calculate_timing_statistics,
format_duration,
load_oolong_real_dataset,
load_oolong_synth_dataset,
parse_real_answer,
parse_real_context_messages,
parse_synth_answer,
parse_synth_context_messages,
score_real_response,
score_synth_response,
write_json_summary,
)
from .runner_common import (
BaseRunner,
ItemContext,
RunnerConfig,
add_common_arguments,
validate_common_arguments,
)
load_dotenv()
CONTEXT_SIZE_MAP: dict[str, int] = {
"1K": 1024,
"2K": 2 * 1024,
"4K": 4 * 1024,
"8K": 8 * 1024,
"16K": 16 * 1024,
"32K": 32 * 1024,
"64K": 64 * 1024,
"128K": 128 * 1024,
"256K": 256 * 1024,
"512K": 512 * 1024,
"1M": 1024 * 1024,
"2M": 2 * 1024 * 1024,
"4M": 4 * 1024 * 1024,
}
def parse_context_size(size_str: str) -> int:
"""Parse a context-size string into an exact token count."""
normalized = size_str.strip().upper()
if normalized.isdigit():
value = int(normalized)
if value <= 0:
raise ValueError("Context size must be positive")
return value
if normalized in CONTEXT_SIZE_MAP:
return CONTEXT_SIZE_MAP[normalized]
valid_sizes = ", ".join(CONTEXT_SIZE_MAP)
raise ValueError(
f"Invalid context size '{size_str}'. Use one of [{valid_sizes}] or a positive integer token count."
)
class QueryResult(TypedDict):
"""Query execution result for one OOLONG example."""
question: str
expected_answer: str
actual_response: str
score: float
context_length_tokens: int
class TestResult(TypedDict):
"""Single OOLONG example result."""
question_id: str
context_window_id: str
task_group: str
dataset: str
answer_type: str
passed: bool
score: float
error: str | None
start_time: float
end_time: float
duration_seconds: float
query_executed: QueryResult | None
output_lines: list[str]
class OolongRunner(BaseRunner[TestResult]):
"""Execute OOLONG benchmark examples through the shared runner framework."""
variant: str
data_dir: Path
split: str
merge_sessions: bool
max_examples: int | None
min_context_len: int | None
max_context_len: int | None
context_window_id: str | None
use_labels: bool
def __init__(
self,
config: RunnerConfig,
variant: str,
data_dir: Path,
split: str,
merge_sessions: bool,
max_examples: int | None = None,
min_context_len: int | None = None,
max_context_len: int | None = None,
context_window_id: str | None = None,
use_labels: bool = False,
):
self.variant = variant
self.data_dir = data_dir
self.split = split
self.merge_sessions = merge_sessions
self.max_examples = max_examples
self.min_context_len = min_context_len
self.max_context_len = max_context_len
self.context_window_id = context_window_id
self.use_labels = use_labels
super().__init__(config)
def get_metrics_prefix(self) -> str:
return "oolong"
def load_items(self) -> list[Any]:
if self.variant == "synth":
dataset = load_oolong_synth_dataset(
split=self.split,
data_dir=self.data_dir,
max_context_len=self.max_context_len,
min_context_len=self.min_context_len,
max_examples=self.max_examples,
context_window_id=self.context_window_id,
)
else:
dataset = load_oolong_real_dataset(
split=self.split,
data_dir=self.data_dir,
max_context_len=self.max_context_len,
min_context_len=self.min_context_len,
max_examples=self.max_examples,
context_window_id=self.context_window_id,
)
return [dataset[i] for i in range(len(dataset))]
def get_workspace_id(self, item: Any) -> str:
return f"oolong_{self.variant}_{item['id']}"
def get_session_id(self, item: Any, workspace_id: str) -> str:
return f"{workspace_id}_session"
async def setup_peers(self, ctx: ItemContext, item: Any) -> None:
ctx.peers["user"] = await ctx.honcho_client.aio.peer(id="user")
async def setup_session(self, ctx: ItemContext, item: Any) -> None:
if not self.merge_sessions:
ctx.session = None
return
user_peer = ctx.peers["user"]
ctx.session = await ctx.honcho_client.aio.session(
id=ctx.session_id, configuration=self._get_session_configuration()
)
await ctx.session.aio.add_peers(
[(user_peer, SessionPeerConfig(observe_me=True, observe_others=False))]
)
async def _add_messages_to_session(
self, session: Session, user_peer: Any, messages: list[dict[str, Any]]
) -> None:
honcho_messages: list[MessageCreateParams] = []
for msg in messages:
honcho_messages.append(
user_peer.message(
content=msg["content"],
metadata=msg.get("metadata"),
)
)
for i in range(0, len(honcho_messages), 100):
batch = honcho_messages[i : i + 100]
await session.aio.add_messages(batch)
async def ingest_messages(self, ctx: ItemContext, item: Any) -> int:
context_text = item["context_window_text"]
if self.variant == "synth":
if self.use_labels:
context_text = item.get("context_window_text_with_labels", context_text)
messages = parse_synth_context_messages(context_text)
else:
messages = parse_real_context_messages(context_text)
user_peer = ctx.peers["user"]
if self.merge_sessions:
if ctx.session is None:
raise ValueError("Merged mode requires a configured session")
await self._add_messages_to_session(ctx.session, user_peer, messages)
return len(messages)
chunk_size = 200
session_ids: list[str] = []
for idx, start in enumerate(range(0, len(messages), chunk_size)):
chunk = messages[start : start + chunk_size]
session_id = f"{ctx.workspace_id}_session_{idx + 1}"
session = await ctx.honcho_client.aio.session(
id=session_id, configuration=self._get_session_configuration()
)
await session.aio.add_peers(
[(user_peer, SessionPeerConfig(observe_me=True, observe_others=False))]
)
await self._add_messages_to_session(session, user_peer, chunk)
session_ids.append(session_id)
ctx.peers["_session_ids"] = session_ids
return len(messages)
def get_dream_observers(self, item: Any) -> list[str]:
return ["user"]
def get_dream_session_ids(self, ctx: ItemContext, _item: Any) -> list[str]:
if self.merge_sessions:
return [ctx.session_id]
session_ids = ctx.peers.get("_session_ids")
if not isinstance(session_ids, list) or not session_ids:
raise ValueError(
"Non-merged OOLONG mode requires at least one chunk session ID for dreams"
)
session_ids_typed = cast(list[object], session_ids)
cleaned_session_ids: list[str] = []
for maybe_session_id in session_ids_typed:
if isinstance(maybe_session_id, str) and maybe_session_id:
cleaned_session_ids.append(maybe_session_id)
if not cleaned_session_ids:
raise ValueError(
"Non-merged OOLONG mode has no valid chunk session IDs for dreams"
)
return cleaned_session_ids
async def execute_questions(self, ctx: ItemContext, item: Any) -> TestResult:
start_time = time.time()
question_id = item["id"]
context_window_id = item["context_window_id"]
question = item["question"]
answer_str = item["answer"]
if self.variant == "synth":
task_group = item.get("task_group", "unknown")
dataset_name = item.get("dataset", "oolong-synth")
answer_type = item.get("answer_type", "unknown")
gold_answer = parse_synth_answer(answer_str)
else:
task_group = item.get("question_type", "unknown")
dataset_name = "oolong-real"
answer_type = "varied"
gold_answer = parse_real_answer(answer_str)
context_text = item["context_window_text"]
if self.variant == "synth" and self.use_labels:
context_text = item.get("context_window_text_with_labels", context_text)
context_length = calculate_context_length(context_text)
result: TestResult = {
"question_id": question_id,
"context_window_id": context_window_id,
"task_group": task_group,
"dataset": dataset_name,
"answer_type": answer_type,
"passed": False,
"score": 0.0,
"error": None,
"start_time": start_time,
"end_time": 0.0,
"duration_seconds": 0.0,
"query_executed": None,
"output_lines": [],
}
user_peer = ctx.peers["user"]
try:
chat_kwargs: dict[str, Any] = {}
if self.config.reasoning_level:
chat_kwargs["reasoning_level"] = self.config.reasoning_level
if self.merge_sessions and ctx.session is not None:
chat_kwargs["session"] = ctx.session
response = await user_peer.aio.chat(question, **chat_kwargs)
actual_response = response if isinstance(response, str) else ""
if self.variant == "synth":
score = score_synth_response(gold_answer, actual_response, answer_type)
else:
score = score_real_response(gold_answer, actual_response)
result["query_executed"] = QueryResult(
question=question,
expected_answer=str(gold_answer),
actual_response=actual_response,
score=score,
context_length_tokens=context_length,
)
result["score"] = score
result["passed"] = score >= 0.99
result["output_lines"] = [
f"Question: {question}",
f"Expected: {gold_answer}",
f"Score: {score:.3f}",
]
except Exception as e:
result["error"] = str(e)
result["query_executed"] = QueryResult(
question=question,
expected_answer=str(gold_answer),
actual_response=f"ERROR: {e}",
score=0.0,
context_length_tokens=context_length,
)
result["end_time"] = time.time()
result["duration_seconds"] = result["end_time"] - result["start_time"]
return result
def print_summary(self, results: list[TestResult], total_duration: float) -> None:
total_examples = len(results)
perfect_scores = sum(1 for r in results if r["score"] >= 0.99)
average_score = (
sum(result["score"] for result in results) / total_examples
if total_examples
else 0.0
)
print(f"\n{'=' * 80}")
print(f"OOLONG-{self.variant.upper()} BENCHMARK SUMMARY")
print(f"{'=' * 80}")
print(f"Total examples: {total_examples}")
print(f"Average score: {average_score:.3f}")
perfect_rate = (
(perfect_scores / total_examples) * 100 if total_examples else 0.0
)
print(f"Perfect scores (>=0.99): {perfect_scores} ({perfect_rate:.1f}%)")
print(f"Total test time: {format_duration(total_duration)}")
task_stats = calculate_task_statistics(results)
if task_stats:
print("\nTask group statistics:")
for task_name, stats in sorted(task_stats.items()):
print(
f" {task_name}: avg={stats['average_score']:.3f}, perfect={stats['perfect_score_rate']:.1f}% ({stats['total']})"
)
print(f"{'=' * 80}")
def generate_output(self, results: list[TestResult], total_duration: float) -> None:
total_examples = len(results)
perfect_scores = sum(1 for r in results if r["score"] >= 0.99)
average_score = (
sum(result["score"] for result in results) / total_examples
if total_examples
else 0.0
)
task_stats = calculate_task_statistics(results)
timing_stats = calculate_timing_statistics(results, total_duration)
summary: dict[str, Any] = {
"metadata": {
"benchmark": "oolong",
"variant": self.variant,
"split": self.split,
"data_dir": str(self.data_dir),
"execution_timestamp": datetime.now().isoformat(),
"runner_version": "2.0.0",
"base_api_port": self.config.base_api_port,
"pool_size": self.config.pool_size,
"timeout_seconds": self.config.timeout_seconds,
"merge_sessions": self.merge_sessions,
"labels": self.use_labels,
"reasoning_level": self.config.reasoning_level,
"deriver_settings": settings.DERIVER.model_dump(),
"dialectic_settings": settings.DIALECTIC.model_dump(),
"dream_settings": settings.DREAM.model_dump(),
},
"summary_statistics": {
"total_examples": total_examples,
"perfect_scores": perfect_scores,
"perfect_score_rate": perfect_scores / total_examples
if total_examples
else 0.0,
"average_score": average_score,
"statistics_by_task_group": task_stats,
},
"timing": timing_stats,
"detailed_results": [
{
"question_id": result["question_id"],
"context_window_id": result["context_window_id"],
"task_group": result["task_group"],
"dataset": result["dataset"],
"answer_type": result["answer_type"],
"score": result["score"],
"passed": result["passed"],
"error": result["error"],
"duration_seconds": result["duration_seconds"],
"query_executed": result["query_executed"],
}
for result in results
],
}
if self.config.json_output:
output_file = self.config.json_output
else:
output_file = Path(
f"tests/bench/eval_results/oolong_{self.variant}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
)
write_json_summary(summary, output_file)
def main() -> int:
parser = argparse.ArgumentParser(
description="Run OOLONG benchmark tests against a Honcho instance",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --variant synth --data-dir /datasets/oolong-synth
%(prog)s --variant real --data-dir /datasets/oolong-real --split validation
%(prog)s --variant synth --data-dir /datasets/oolong-synth --context-size 16K
%(prog)s --variant synth --data-dir /datasets/oolong-synth --no-merge-sessions
""",
)
parser.add_argument(
"--variant",
type=str,
default="synth",
choices=["synth", "real"],
help="Which OOLONG variant to run (default: synth)",
)
parser.add_argument(
"--data-dir",
type=Path,
required=True,
help="Path to the dataset directory",
)
parser.add_argument(
"--split",
type=str,
default="test",
choices=["test", "validation"],
help="Dataset split to use (default: test)",
)
parser.add_argument(
"--max-examples",
type=int,
default=None,
help="Maximum number of examples to run (default: all)",
)
parser.add_argument(
"--context-size",
type=str,
default=None,
help=(
"Context-size bucket cap, e.g. 8K, 16K, 1M, or exact token count like 16384. "
"Sets --max-context-len; --min-context-len is only kept when explicitly passed."
),
)
parser.add_argument(
"--max-context-len",
type=int,
default=None,
help="Maximum context length in tokens",
)
parser.add_argument(
"--labels",
action="store_true",
default=False,
help="Use context_window_text_with_labels for synth examples",
)
parser.add_argument(
"--min-context-len",
type=int,
default=1024,
help="Minimum context length in tokens (default: 1024, upstream behavior)",
)
parser.add_argument(
"--context-window-id",
type=str,
default=None,
help="Run only examples with this context_window_id",
)
parser.add_argument(
"--no-merge-sessions",
action="store_false",
dest="merge_sessions",
default=True,
help="Store context across multiple sessions instead of a merged session",
)
add_common_arguments(parser)
args = parser.parse_args()
min_context_len_explicit = "--min-context-len" in sys.argv
error = validate_common_arguments(args)
if error:
print(error)
return 1
if args.use_get_context:
print("Error: --use-get-context is not supported by the OOLONG runner")
return 1
if not args.data_dir.exists():
print(f"Error: data directory does not exist: {args.data_dir}")
return 1
if args.max_examples is not None and args.max_examples <= 0:
print(f"Error: max examples must be positive, got {args.max_examples}")
return 1
if args.context_size:
try:
exact_size = parse_context_size(args.context_size)
except ValueError as e:
print(f"Error: {e}")
return 1
# OOLONG-style behavior: context-size is a bucket cap. Preserve
# an explicit lower bound only when the user provides one.
args.max_context_len = exact_size
if not min_context_len_explicit:
args.min_context_len = None
print(
f"Using context-size cap: <= {exact_size} tokens"
+ (
f" (min: {args.min_context_len})"
if args.min_context_len is not None
else ""
)
)
if args.min_context_len is not None and args.min_context_len < 0:
print(f"Error: min context len must be >= 0, got {args.min_context_len}")
return 1
if args.max_context_len is not None and args.max_context_len <= 0:
print(f"Error: max context len must be positive, got {args.max_context_len}")
return 1
if (
args.max_context_len is not None
and args.min_context_len is not None
and args.max_context_len < args.min_context_len
):
print(
f"Error: max context len must be >= min context len ({args.max_context_len} < {args.min_context_len})"
)
return 1
config = RunnerConfig.from_args(args, default_timeout=600)
runner = OolongRunner(
config=config,
variant=args.variant,
data_dir=args.data_dir,
split=args.split,
merge_sessions=args.merge_sessions,
max_examples=args.max_examples,
min_context_len=args.min_context_len,
max_context_len=args.max_context_len,
context_window_id=args.context_window_id,
use_labels=args.labels,
)
return runner.run_and_summarize()
if __name__ == "__main__":
exit(main())

View File

@ -0,0 +1,750 @@
"""
Common utilities for OOLONG benchmark test runners.
Based on the OOLONG paper:
- OOLONG-synth: Synthetic ICL-based aggregation tasks
- OOLONG-real: Real D&D transcript aggregation tasks
"""
import ast
import json
import logging
import re
from collections.abc import Callable, Sequence
from datetime import date, datetime
from pathlib import Path
from typing import Any, cast
import dateutil.parser
import pyarrow.parquet as pq
import tiktoken
from typing_extensions import TypedDict
logger = logging.getLogger(__name__)
class SimpleDataset:
"""Simple dataset class that mimics HuggingFace dataset API."""
data: list[dict[str, Any]]
column_names: list[str]
def __init__(self, data: list[dict[str, Any]]):
"""Initialize dataset with list of examples.
Args:
data: List of example dictionaries
"""
self.data = data
self.column_names = list(data[0].keys()) if data else []
def __len__(self) -> int:
"""Return number of examples."""
return len(self.data)
def __getitem__(self, idx: int) -> dict[str, Any]:
"""Get example by index."""
return self.data[idx]
def filter(self, function: Callable[[dict[str, Any]], bool]) -> "SimpleDataset":
"""Filter dataset using a function.
Args:
function: Filter function
Returns:
Filtered dataset
"""
filtered_data = [item for item in self.data if function(item)]
return SimpleDataset(filtered_data)
def select(self, indices: Sequence[int]) -> "SimpleDataset":
"""Select examples by indices.
Args:
indices: List of indices to select
Returns:
Dataset with selected examples
"""
selected_data = [self.data[i] for i in indices]
return SimpleDataset(selected_data)
class BaseQueryResult(TypedDict):
"""Base type definition for query execution results."""
question: str
expected_answer: str
actual_response: str
score: float
context_length_tokens: int
class BaseTestResult(TypedDict):
"""Base type definition for test execution results."""
question_id: str
task_group: str
dataset: str
passed: bool
score: float
error: str | None
start_time: float
end_time: float
duration_seconds: float
output_lines: list[str]
def format_duration(total_seconds: float) -> str:
"""Format a duration in seconds into a human-readable string.
If the duration is at least one minute, this returns a string in the
form "XmYYs" with zero-padded seconds. Otherwise, it returns the
duration in seconds with two decimal places, e.g., "12.34s".
Args:
total_seconds: The duration in seconds.
Returns:
A formatted duration string.
"""
minutes = int(total_seconds // 60)
if minutes > 0:
seconds_rounded = int(round(total_seconds - minutes * 60))
if seconds_rounded == 60:
minutes += 1
seconds_rounded = 0
return f"{minutes}m{seconds_rounded:02d}s"
return f"{total_seconds:.2f}s"
def calculate_context_length(text: str) -> int:
"""Calculate token count for context text using tiktoken.
Args:
text: Context text to count tokens for
Returns:
Number of tokens
"""
try:
tokenizer = tiktoken.get_encoding("o200k_base")
return len(
tokenizer.encode(
text,
disallowed_special=(tokenizer.special_tokens_set - {"<|endoftext|>"}),
)
)
except Exception:
# Fallback to character-based estimate
return len(text) // 4
def load_oolong_synth_dataset(
split: str = "test",
data_dir: str | Path | None = None,
max_context_len: int | None = None,
min_context_len: int | None = None,
max_examples: int | None = None,
context_window_id: str | None = None,
) -> SimpleDataset:
"""Load the OOLONG-synth dataset from filesystem.
Args:
split: Dataset split to load (default: "test")
data_dir: Path to the oolong-synth dataset directory (must contain a 'data' subdirectory)
max_context_len: Maximum context length in tokens
min_context_len: Minimum context length in tokens (strict >, upstream behavior)
max_examples: Maximum number of examples to return
context_window_id: Specific context window ID to filter to
Returns:
SimpleDataset object
Raises:
ValueError: If data_dir is not provided
FileNotFoundError: If no parquet files found for the split
"""
if data_dir is None:
raise ValueError(
"data_dir parameter is required. Please provide the path to the oolong-synth dataset."
)
dataset_path = Path(data_dir) / "data"
if not dataset_path.exists():
raise FileNotFoundError(
f"Expected synth dataset directory at {dataset_path} (layout: data/*.parquet)"
)
# Find all parquet files for the given split
parquet_files = sorted(dataset_path.glob(f"{split}-*.parquet"))
if not parquet_files:
raise FileNotFoundError(f"No {split} parquet files found in {dataset_path}")
# Stream rows from parquet shards in batches so we can filter and stop early
# without materializing the full split in memory first.
all_data: list[dict[str, Any]] = []
for parquet_file in parquet_files:
parquet = pq.ParquetFile(parquet_file)
for batch in parquet.iter_batches(): # pyright: ignore[reportUnknownVariableType]
rows = cast(
list[dict[str, Any]],
batch.to_pylist(), # pyright: ignore[reportUnknownMemberType]
)
for row in rows:
if (
context_window_id is not None
and str(row.get("context_window_id")) != context_window_id
):
continue
context_len = row.get("context_len")
if not isinstance(context_len, int):
context_len = calculate_context_length(
str(row.get("context_window_text", ""))
)
if max_context_len is not None and context_len > max_context_len:
continue
# Keep strict greater-than for consistency with existing behavior.
if min_context_len is not None and context_len <= min_context_len:
continue
all_data.append(row)
if (
max_examples is not None
and max_examples > 0
and len(all_data) >= max_examples
):
return SimpleDataset(all_data)
return SimpleDataset(all_data)
def load_oolong_real_dataset(
split: str = "test",
data_dir: str | Path | None = None,
max_context_len: int | None = None,
min_context_len: int | None = None,
max_examples: int | None = None,
context_window_id: str | None = None,
) -> SimpleDataset:
"""Load the OOLONG-real dataset from filesystem.
Args:
split: Dataset split to load (default: "test")
data_dir: Path to the oolong-real dataset directory (must contain a 'dnd' subdirectory)
max_context_len: Maximum context length in tokens
min_context_len: Minimum context length in tokens (strict >, upstream behavior)
max_examples: Maximum number of examples to return
context_window_id: Specific context window ID to filter to
Returns:
SimpleDataset object
Raises:
ValueError: If data_dir is not provided
FileNotFoundError: If JSONL file not found
"""
if data_dir is None:
raise ValueError(
"data_dir parameter is required. Please provide the path to the oolong-real dataset."
)
dataset_path = Path(data_dir) / "dnd"
if not dataset_path.exists():
raise FileNotFoundError(
f"Expected real dataset directory at {dataset_path} (layout: dnd/*.jsonl)"
)
jsonl_file = dataset_path / f"{split}.jsonl"
if not jsonl_file.exists():
raise FileNotFoundError(f"JSONL file not found: {jsonl_file}")
# Load JSONL file
data: list[dict[str, Any]] = []
with open(jsonl_file) as f:
for line in f:
if line.strip():
row_raw = json.loads(line)
if not isinstance(row_raw, dict):
continue
row = cast(dict[str, Any], row_raw)
if (
context_window_id is not None
and str(row.get("context_window_id")) != context_window_id
):
continue
context_len = row.get("context_len")
if not isinstance(context_len, int):
context_len = calculate_context_length(
str(row.get("context_window_text", ""))
)
if max_context_len is not None and context_len > max_context_len:
continue
# Keep strict greater-than for consistency with existing behavior.
if min_context_len is not None and context_len <= min_context_len:
continue
data.append(row)
if (
max_examples is not None
and max_examples > 0
and len(data) >= max_examples
):
return SimpleDataset(data)
return SimpleDataset(data)
def parse_synth_context_messages(context_text: str) -> list[dict[str, Any]]:
"""Parse OOLONG-synth context text into individual messages.
Context format:
Date: YYYY-MM-DD || User: user_XYZ || Instance: <text> [label]
Args:
context_text: Raw context window text
Returns:
List of message dictionaries with content and metadata
"""
messages: list[dict[str, Any]] = []
# Split by lines and parse each entry
lines = context_text.strip().split("\n")
for line in lines:
if not line.strip():
continue
# Parse: Date: ... || User: ... || Instance: ... || Label: ...
try:
parts = line.split(" || ")
if len(parts) < 3:
logger.warning(f"Skipping malformed line: {line}")
continue
date_part = parts[0].replace("Date: ", "").strip()
user_part = parts[1].replace("User: ", "").strip()
instance_part = parts[2].replace("Instance: ", "").strip()
# Check if there's a 4th part with label
label = None
if len(parts) >= 4:
label_part = parts[3].replace("Label: ", "").strip()
label = label_part if label_part else None
# Include label in content so deriver can observe it.
content = f"{instance_part} [Label: {label}]" if label else instance_part
msg: dict[str, Any] = {
"content": content,
"metadata": {
"date": date_part,
"user_id": user_part,
"label": label,
},
}
messages.append(msg)
except Exception as e:
logger.warning(f"Error parsing line: {line}. Error: {e}")
continue
return messages
def parse_real_context_messages(context_text: str) -> list[dict[str, Any]]:
"""Parse OOLONG-real D&D transcript into individual messages.
Context format:
Speaker: dialogue text
[multiple lines]
Args:
context_text: Raw D&D transcript text
Returns:
List of message dictionaries with content and metadata
"""
messages: list[dict[str, Any]] = []
# Split by speaker turns (format: "SPEAKER: text")
lines = context_text.strip().split("\n")
current_speaker = None
current_content = []
for line in lines:
if not line.strip():
continue
# Check if this is a new speaker turn (must start with speaker label)
# Speaker labels are typically single words or use underscores/hyphens (no spaces)
speaker_match = re.match(r"^\s*([A-Za-z0-9_-]+):", line)
if speaker_match:
# Save previous message if exists
if current_speaker and current_content:
# Include speaker in content for deriver visibility
content_text = " ".join(current_content)
content = f"[Speaker: {current_speaker}] {content_text}"
prev_msg: dict[str, Any] = {
"content": content,
"metadata": {
"speaker": current_speaker,
},
}
messages.append(prev_msg)
# Parse new speaker from regex match
current_speaker = speaker_match.group(1).strip()
# Extract content after the colon
content_after_colon = line[speaker_match.end() :].strip()
current_content = [content_after_colon] if content_after_colon else []
else:
# Continuation of current speaker's dialogue
current_content.append(line.strip())
# Save last message
if current_speaker and current_content:
content_text = " ".join(current_content)
content = f"[Speaker: {current_speaker}] {content_text}"
last_msg: dict[str, Any] = {
"content": content,
"metadata": {
"speaker": current_speaker,
},
}
messages.append(last_msg)
return messages
def parse_synth_answer(answer_str: str) -> Any:
"""Parse OOLONG-synth answer string.
Answers can be:
- Strings (labels, comparisons, dates)
- Numbers (counts)
- Dates (datetime objects)
Args:
answer_str: Raw answer string from dataset
Returns:
Parsed answer value
"""
# Handle datetime answers
if "datetime" in answer_str:
try:
# Format: [datetime.date(2023, 5, 15)]
match = re.search(r"datetime\.date\((\d+),\s*(\d+),\s*(\d+)\)", answer_str)
if match:
year, month, day = map(int, match.groups())
return datetime(year, month, day).date()
except Exception as e:
logger.warning(f"Error parsing datetime answer: {answer_str}. Error: {e}")
return answer_str
# Try literal eval for lists/primitives
try:
parsed = ast.literal_eval(answer_str)
# If it's a list with one element, return that element
if isinstance(parsed, list) and len(parsed) == 1: # pyright: ignore[reportUnknownArgumentType]
return parsed[0] # pyright: ignore[reportUnknownVariableType]
return parsed # pyright: ignore[reportUnknownVariableType]
except (ValueError, SyntaxError):
# Return as-is if can't parse
return answer_str
def parse_real_answer(answer_str: str) -> int | str | list[str]:
"""Parse OOLONG-real answer string.
Answers can be:
- Integers (counts)
- Strings (spell names, roll types)
- Lists (comma-separated spells)
Args:
answer_str: Raw answer string from dataset
Returns:
Parsed answer value
"""
# Try to convert to int first
try:
return int(answer_str)
except ValueError:
pass
# Check if it contains commas (list case)
if "," in answer_str:
return [item.strip() for item in answer_str.split(",") if item.strip()]
# Otherwise return as string
return answer_str
def score_synth_response(
gold_answer: Any, model_answer_str: str, answer_type: str
) -> float:
"""Score a response for OOLONG-synth following the paper's scoring rubric.
Scoring:
- Exact match: 1.0
- Numeric: 0.75^|y-ŷ| (partial credit)
- Other: 0.0
Args:
gold_answer: Expected answer
model_answer_str: Model's response string
answer_type: Type of answer (ANSWER_TYPE.NUMERIC, ANSWER_TYPE.DATE, etc.)
Returns:
Score between 0.0 and 1.0
"""
# Try to extract answer from response
# Look for common patterns at start of line or end of response
model_answer = model_answer_str.strip()
# Try to extract from common formats (use MULTILINE to match at line start, and $ to find at line end)
# Look for patterns at the start of a line (final answer format)
for pattern in [
r"^[Aa]nswer:\s*(.+)$", # "Answer: X" on its own line
r"^[Ll]abel:\s*(.+)$", # "Label: X" on its own line
r"\n[Ll]abel:\s*(.+)$", # "Label: X" after a newline (final answer)
r"[Aa]nswer:\s*(.+)$", # "Answer: X" at end of response
r"[Uu]ser:\s*(.+)$", # "User: X" at end
r"[Dd]ate:\s*(.+)$", # "Date: X" at end
]:
match = re.search(pattern, model_answer, re.MULTILINE)
if match:
model_answer = match.group(1).strip()
break
# Remove formatting like ** or []
model_answer = re.sub(r"[\*\[\]]", "", model_answer)
# If still long, try last significant token
if len(model_answer) > 50:
model_answer = model_answer.split()[-1]
# Exact string match
if str(model_answer).lower() == str(gold_answer).lower():
return 1.0
# Check for comparison answers
if (
"more common" in model_answer.lower()
and "more common" in str(gold_answer).lower()
):
return 1.0
if (
"less common" in model_answer.lower()
and "less common" in str(gold_answer).lower()
):
return 1.0
if (
"same frequency" in model_answer.lower()
and "same frequency" in str(gold_answer).lower()
):
return 1.0
# Numeric partial credit
if "NUMERIC" in answer_type.upper():
try:
model_num = float(re.sub(r"[^\d.-]", "", model_answer))
gold_num = float(gold_answer)
return 0.75 ** abs(gold_num - model_num)
except (ValueError, TypeError):
return 0.0
# Date matching
if "DATE" in answer_type.upper():
try:
model_date = dateutil.parser.parse(model_answer)
if isinstance(gold_answer, datetime):
return 1.0 if model_date.date() == gold_answer.date() else 0.0
elif isinstance(gold_answer, date):
return 1.0 if model_date.date() == gold_answer else 0.0
return 0.0
except (ValueError, TypeError):
return 0.0
return 0.0
def score_real_response(
gold_answer: int | str | list[str], model_answer_str: str
) -> float:
"""Score a response for OOLONG-real following the paper's scoring rubric.
Scoring:
- Integer: 0.75^|y-ŷ| (partial credit)
- String: exact match (1.0 or 0.0)
- List: set overlap / |gold| (Jaccard-style)
Args:
gold_answer: Expected answer
model_answer_str: Model's response string
Returns:
Score between 0.0 and 1.0
"""
# Extract answer from \boxed{} format if present
match = re.search(r"\\boxed\{\\text\{([^}]*)\}\}", model_answer_str) or re.search(
r"\\boxed[\{]+([^}]*)[\}]+", model_answer_str
)
if match:
model_answer_str = match.group(1)
# Parse model answer
try:
model_answer: int | str | list[str] = int(model_answer_str)
except ValueError:
if "," in model_answer_str:
model_answer = [
item.strip() for item in model_answer_str.split(",") if item.strip()
]
else:
model_answer = model_answer_str.strip()
# Score based on type
if isinstance(gold_answer, int) and isinstance(model_answer, int):
return 0.75 ** abs(gold_answer - model_answer)
elif isinstance(gold_answer, str) and isinstance(model_answer, str):
return 1.0 if gold_answer.lower() == model_answer.lower() else 0.0
elif isinstance(gold_answer, list) and isinstance(model_answer, list):
overlap = set(gold_answer) & set(model_answer)
return len(overlap) / len(gold_answer) if gold_answer else 0.0
else:
return 0.0
def filter_dataset(
dataset: SimpleDataset,
max_context_len: int | None = None,
min_context_len: int | None = None,
max_examples: int | None = None,
context_window_id: str | None = None,
) -> SimpleDataset:
"""Filter dataset by context length and example count.
Args:
dataset: HuggingFace dataset
max_context_len: Maximum context length in tokens
min_context_len: Minimum context length in tokens
max_examples: Maximum number of examples to return
context_window_id: Specific context window ID to filter to
Returns:
Filtered dataset
"""
if context_window_id is not None:
dataset = dataset.filter(lambda x: x["context_window_id"] == context_window_id)
if max_context_len is not None:
dataset = dataset.filter(
lambda x: x.get(
"context_len",
calculate_context_length(str(x.get("context_window_text", ""))),
)
<= max_context_len
)
if min_context_len is not None:
dataset = dataset.filter(
lambda x: x.get(
"context_len",
calculate_context_length(str(x.get("context_window_text", ""))),
)
> min_context_len
)
if max_examples is not None and max_examples > 0:
# Get a slice of the dataset
dataset = dataset.select(range(min(max_examples, len(dataset))))
return dataset
def calculate_task_statistics(
results: Sequence[Any],
) -> dict[str, dict[str, int | float]]:
"""Calculate score statistics grouped by task type.
Args:
results: List of test results
Returns:
Dictionary mapping task type to statistics
"""
task_stats: dict[str, dict[str, int | float]] = {}
for result in results:
task_key = result.get("task_group", "unknown")
if task_key not in task_stats:
task_stats[task_key] = {"total": 0, "total_score": 0.0, "perfect_scores": 0}
task_stats[task_key]["total"] += 1
score = result.get("score", 0.0)
task_stats[task_key]["total_score"] += score
if score >= 0.99: # Consider >= 0.99 as perfect to account for floating point
task_stats[task_key]["perfect_scores"] += 1
# Calculate averages
for task_key in task_stats:
stats = task_stats[task_key]
total = stats["total"]
stats["average_score"] = stats["total_score"] / total if total > 0 else 0.0
stats["perfect_score_rate"] = (
(stats["perfect_scores"] / total) * 100 if total > 0 else 0.0
)
return task_stats
def calculate_timing_statistics(
results: Sequence[Any], total_elapsed_seconds: float
) -> dict[str, Any]:
"""Calculate timing statistics from test results.
Args:
results: List of test results
total_elapsed_seconds: Total elapsed time for all tests
Returns:
Dictionary of timing statistics
"""
durations = [r["duration_seconds"] for r in results]
return {
"total_duration_seconds": total_elapsed_seconds,
"individual_test_durations": {
"min_seconds": min(durations) if durations else 0,
"max_seconds": max(durations) if durations else 0,
"mean_seconds": sum(durations) / len(durations) if durations else 0,
"median_seconds": sorted(durations)[len(durations) // 2]
if durations
else 0,
},
}
def write_json_summary(summary: dict[str, Any], output_file: Path) -> None:
"""Write a JSON summary to a file.
Args:
summary: Summary dictionary to write
output_file: Path to output file
"""
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, "w") as f:
json.dump(summary, f, indent=2, default=str)
print(f"\nJSON summary written to: {output_file}")

View File

@ -373,6 +373,15 @@ class BaseRunner(ABC, Generic[ResultT]):
"""Return list of peer IDs to trigger dreams for."""
...
def get_dream_session_ids(self, ctx: ItemContext, _item: Any) -> list[str]:
"""Return session IDs to use for dream scheduling.
Subclasses can override this when ingestion stores messages across
multiple sessions and each session should be included in dream
scheduling.
"""
return [ctx.session_id]
@abstractmethod
async def execute_questions(self, ctx: ItemContext, item: Any) -> ResultT:
"""
@ -518,15 +527,29 @@ class BaseRunner(ABC, Generic[ResultT]):
)
# Trigger dreams
print(f"[{workspace_id}] Deriver queue empty. Triggering dreams...")
for observer in self.get_dream_observers(item):
success = await self._trigger_dream(
ctx.honcho_client, workspace_id, observer, session_id
dream_observers = self.get_dream_observers(item)
dream_session_ids = self.get_dream_session_ids(ctx, item)
if not dream_session_ids:
raise ValueError(
f"No dream session IDs available for {workspace_id}. "
+ "Dream scheduling requires at least one session id."
)
if not success:
print(
f"[{workspace_id}] Warning: Dream for {observer} did not complete"
print(
f"[{workspace_id}] Deriver queue empty. Triggering dreams for "
+ f"{len(dream_observers)} observer(s) across "
+ f"{len(dream_session_ids)} session(s)..."
)
for observer in dream_observers:
for dream_session_id in dream_session_ids:
success = await self._trigger_dream(
ctx.honcho_client, workspace_id, observer, dream_session_id
)
if not success:
print(
f"[{workspace_id}] Warning: Dream for {observer} in "
+ f"session {dream_session_id} did not complete"
)
# Execute questions
print(f"[{workspace_id}] Executing questions...")

View File

@ -1303,6 +1303,7 @@ dependencies = [
{ name = "pgvector" },
{ name = "prometheus-client" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pyarrow" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
@ -1359,6 +1360,7 @@ requires-dist = [
{ name = "pgvector", specifier = ">=0.2.5" },
{ name = "prometheus-client", specifier = ">=0.21.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.19" },
{ name = "pyarrow", specifier = ">=19.0.0" },
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "pydantic-settings", specifier = ">=2.10.1" },
{ name = "pyjwt", specifier = ">=2.10.0" },