| """Build simulated users and ordered sessions from BFCL tasks. |
| |
| The persistence/lifecycle claim (ToolSpec's own flagged gap) is about a memory |
| that must keep absorbing tool-use patterns *introduced after* the datastore was |
| first built. A frozen datastore cannot contain tools/workflows it never saw; an |
| online personal memory incorporates them. To test this we introduce each user's |
| signature tasks PROGRESSIVELY across sessions: roughly half are "known" (present |
| at warmup, so the frozen static datastore has them) and half are "novel" (first |
| appear only in later sessions, so the frozen store has never seen them). Novel |
| tasks recur after their introduction -- the recurring, evolving per-user usage a |
| persistent memory is meant to exploit. |
| """ |
| from __future__ import annotations |
|
|
| import random |
| from dataclasses import dataclass |
| from typing import Any |
|
|
| from .data import Task, perturb_numeric |
|
|
|
|
| @dataclass |
| class Instance: |
| user_id: str |
| session: int |
| query: str |
| functions: list[dict[str, Any]] |
| signature_id: str |
| novel: bool |
|
|
|
|
| def build_users(tasks: list[Task], n_users: int, tasks_per_user: int, |
| n_sessions: int, queries_per_session: int, |
| warmup_frac: float = 0.5, seed: int = 0, |
| perturb_prob: float = 1.0, arrival: str = "spread", |
| novel_weight: float = 3.0, overlap_frac: float = 0.0, |
| user_consistent: bool = False) -> list[Instance]: |
| """Assign disjoint signature tasks to users and roll out their sessions. |
| |
| Each signature task j gets an introduction session: the first |
| ``n_known = round(tasks_per_user * warmup_frac)`` are introduced at session |
| 0 (warmup); the remaining "novel" tasks are introduced one-by-one across the |
| later sessions. A session issues queries drawn only from tasks introduced so |
| far, biased toward the most recently introduced (novel) task so the novel |
| load actually shows up in the metric. |
| """ |
| rng = random.Random(seed) |
| |
| |
| coin = random.Random(seed + 991) |
| pool = tasks[:] |
| rng.shuffle(pool) |
|
|
| |
| |
| |
| |
| |
| |
| |
| overlap_mode = user_consistent or overlap_frac > 0.0 |
| if overlap_mode: |
| n_shared = min(tasks_per_user, round(tasks_per_user * overlap_frac)) |
| n_private = tasks_per_user - n_shared |
| need = tasks_per_user + n_users * n_private |
| if len(pool) < need: |
| raise ValueError(f"need {need} tasks, have {len(pool)}") |
| shared_pool = pool[:tasks_per_user][:n_shared] |
| priv_pool = pool[tasks_per_user:] |
| else: |
| need = n_users * tasks_per_user |
| if len(pool) < need: |
| raise ValueError(f"need {need} tasks, have {len(pool)}") |
|
|
| n_known = max(1, round(tasks_per_user * warmup_frac)) |
| n_novel = tasks_per_user - n_known |
| |
| if n_novel > 0: |
| if arrival == "burst": |
| novel_intro = [2] * n_novel |
| elif arrival == "late": |
| novel_intro = [max(1, n_sessions - 3)] * n_novel |
| else: |
| step = max(1, (n_sessions - 1) // (n_novel + 1)) |
| novel_intro = [min(n_sessions - 1, 1 + step * (k + 1)) |
| for k in range(n_novel)] |
| else: |
| novel_intro = [] |
| intro = [0] * n_known + novel_intro |
|
|
| instances: list[Instance] = [] |
| cursor = 0 |
| for u in range(n_users): |
| uid = f"user_{u:02d}" |
| user_fixed_q = None |
| if overlap_mode: |
| priv = priv_pool[u * n_private:(u + 1) * n_private] |
| sig = list(shared_pool) + list(priv) |
| |
| |
| |
| |
| |
| urng = random.Random(seed * 100003 + u) |
| user_fixed_q = [perturb_numeric(t.query, urng) for t in sig] |
| else: |
| sig = pool[cursor:cursor + tasks_per_user] |
| cursor += tasks_per_user |
| novel_flags = [False] * n_known + [True] * n_novel |
| for s in range(n_sessions): |
| active = [(sig[j], novel_flags[j], intro[j]) |
| for j in range(tasks_per_user) if intro[j] <= s] |
| |
| |
| active_idx = [j for j in range(tasks_per_user) if intro[j] <= s] |
| if s == 0: |
| chosen = [(j, novel_flags[j]) for j in active_idx] |
| else: |
| weights = [novel_weight if intro[j] == s else 1.0 |
| for j in active_idx] |
| chosen = [] |
| for _ in range(queries_per_session): |
| j = rng.choices(active_idx, weights=weights, k=1)[0] |
| chosen.append((j, novel_flags[j])) |
| for j, nv in chosen: |
| t = sig[j] |
| if user_fixed_q is not None: |
| q = user_fixed_q[j] |
| elif s == 0: |
| q = t.query |
| elif perturb_prob >= 1.0: |
| q = perturb_numeric(t.query, rng) |
| elif perturb_prob <= 0.0: |
| q = t.query |
| else: |
| q = (perturb_numeric(t.query, rng) |
| if coin.random() < perturb_prob else t.query) |
| instances.append(Instance(uid, s, q, t.functions, t.id, nv)) |
| return instances |
|
|