"""TANDEM simulator agent — decomposed and monolithic variants. Both variants call the same Groq LLaMA-3.1-8B endpoint; the difference is the final instruction line of the system prompt: - decomposed: simulator's task is purely persona-conditioned review/rating prediction. - monolithic: simulator's task additionally embeds the ranking-recommendation framing inside the same prompt — testing whether decomposition isolates the simulator from ranking-context confounds (H7). """ from __future__ import annotations import json import re from pathlib import Path from typing import Literal from tqdm import tqdm from src.llm.cache import deterministic_seed from src.llm.client import MODEL_FAST, GroqClient, LLMResponse from src.overlay import Condition, apply_overlay Architecture = Literal["decomposed", "monolithic"] _DECOMPOSED_INSTRUCTION = ( "Your sole task is to predict — as faithfully as possible — how this user " "would rate and review this candidate item. Be honest about negatives where " "the user's history suggests they would dislike it. Do not adjust the rating " "for any other purpose." ) _MONOLITHIC_INSTRUCTION = ( "Your task is to predict how this user would rate and review the candidate " "item, with the understanding that the predicted rating will be used to RANK " "this candidate against other items for recommendation. Higher predicted " "ratings will surface this candidate higher in the user's feed." ) def _build_system_prompt(persona: dict, condition: Condition, architecture: Architecture, item: dict) -> str: base = apply_overlay(persona, condition, item) tail = _DECOMPOSED_INSTRUCTION if architecture == "decomposed" else _MONOLITHIC_INSTRUCTION return base + "\n\n" + tail def predict( client: GroqClient, persona: dict, item: dict, condition: Condition, architecture: Architecture, model: str = MODEL_FAST, ) -> dict: """One simulator prediction. Cached deterministically per (persona_id, item_id, condition, architecture). """ seed = deterministic_seed( persona["persona_id"], item["item_id"], condition, architecture ) system_prompt = _build_system_prompt(persona, condition, architecture, item) response = client.cached_complete( prompt="Output the JSON object now.", seed=seed, model=model, temperature=0.7, max_tokens=240, system=system_prompt, ) return _to_record(response, persona, item, condition, architecture) def _to_record(response: LLMResponse, persona: dict, item: dict, condition: str, architecture: str) -> dict: rating, review = _parse_json_response(response.text) return { "persona_id": persona["persona_id"], "item_id": item["item_id"], "condition": condition, "architecture": architecture, "predicted_rating": rating, "predicted_review": review, "model": response.model, "cached": response.cached, "input_tokens": response.input_tokens, "output_tokens": response.output_tokens, "latency_ms": response.latency_ms, } def _parse_json_response(text: str) -> tuple[float, str]: """Parse rating + review from the LLM output, robust to formatting noise.""" # Locate first JSON object (greedy enough to grab nested-quote reviews) match = re.search(r"\{.*\}", text, re.DOTALL) if match: try: obj = json.loads(match.group()) rating_raw = obj.get("rating", 3) rating = float(rating_raw) rating = max(1.0, min(5.0, rating)) review = str(obj.get("review", "")).strip() return rating, review except (json.JSONDecodeError, TypeError, ValueError): pass # Fallback: look for a "rating: X" pattern; default to 3.0 + raw text. m = re.search(r'rating["\s:]+([1-5])', text, re.IGNORECASE) rating = float(m.group(1)) if m else 3.0 return rating, text.strip()[:400] def run_cell( client: GroqClient, personas: list[dict], items_meta: dict[str, dict], condition: Condition, architecture: Architecture, output_path: Path, ) -> None: """Run one (condition × architecture) cell over each persona's candidate set. Per persona, we predict on `candidate_item_ids` (target + 99 negatives) — the standard SASRec-style protocol. """ output_path.parent.mkdir(parents=True, exist_ok=True) n_total = sum(len(p["candidate_item_ids"]) for p in personas) pbar = tqdm(total=n_total, desc=f"{architecture}/{condition}") with output_path.open("w", encoding="utf-8") as f: for persona in personas: for iid in persona["candidate_item_ids"]: item = items_meta.get(iid) if item is None: pbar.update(1) continue rec = predict(client, persona, item, condition, architecture) f.write(json.dumps(rec, ensure_ascii=False) + "\n") pbar.update(1) pbar.close()