diff --git a/demos/ordering-matters/README.md b/demos/ordering-matters/README.md new file mode 100644 index 0000000..801fa40 --- /dev/null +++ b/demos/ordering-matters/README.md @@ -0,0 +1,92 @@ +# Demo 5: Ordering Matters + +This demo tests whether LLM rule learning depends on **trajectory row ordering**. + +The same underlying trajectory log is shown to the LLM in three views: +- `forward`: chronological order +- `reversed`: reverse chronological order +- `shuffled`: random permutation + +The LLM is asked to infer the behavioral rule that generated the trajectories. +`analysis.py` then measures how much inferred rules diverge across orderings. + +## Files + +- `ordering-matters.nlogo`: NetLogo simulation + ordering/inference/export workflow +- `rule-inference-template.yaml`: prompt template used for rule inference +- `config.txt`: LLM provider settings +- `analysis.py`: rule-divergence analysis + JSON/plot output +- `tests/test_analysis.py`: validation for loader/scoring/report/plot logic +- `results/`: sample inference data, summaries, and plot outputs + +## Model Workflow + +1. `setup` +- Initializes food and agents. +- Agents follow one hidden policy: seek food, avoid local crowding, preserve momentum. + +2. `go` / `go-forever` +- Runs simulation and logs trajectory rows: `tick, agent, x, y, heading, energy, food`. + +3. `infer-rules` +- Builds 3 trajectory presentations: forward/reversed/shuffled. +- Sends each to the same YAML template. +- Stores one inferred rule per ordering. + +4. `export-data` +- Writes trajectories to `results/-trajectories.csv`. +- Writes inferred rules to `results/-inference.csv`. + +## Configure LLM + +Edit `config.txt` and choose one provider block. + +Default file includes examples for: +- OpenAI +- Anthropic +- Gemini +- Ollama + +If you disable `use-llm?` in NetLogo, inference uses a deterministic baseline text (useful for dry runs). + +## Analysis + +Run analysis on any exported inference CSV: + +```bash +cd demos/ordering-matters +python3 analysis.py results/sample-inference.csv \ + --json results/analysis-summary.json \ + --plot results/rule-similarity.png +``` + +### Metrics + +- `jaccard`: token overlap of normalized inferred rules +- `sequence`: string-order similarity (SequenceMatcher) +- `combined`: mean of jaccard + sequence +- `order_dependency_score`: `1 - average(combined_pairwise_similarity)` + +Interpretation: +- Higher `order_dependency_score` => stronger dependence on ordering +- `is_order_sensitive` is true when score exceeds threshold (`--threshold`, default `0.35`) + +## Tests + +```bash +cd demos/ordering-matters +python3 -m pytest tests -q +``` + +## Suggested Experiment Protocol + +1. Run 20+ simulation seeds. +2. Export one inference CSV per run. +3. Analyze each run via `analysis.py`. +4. Aggregate `order_dependency_score` distributions. +5. Compare across model families (e.g., GPT/Claude/Gemini/Ollama). + +## Notes + +- This demo measures **inference sensitivity**, not simulator sensitivity. +- Forward/reversed/shuffled use identical trajectory content; only row order changes. diff --git a/demos/ordering-matters/analysis.py b/demos/ordering-matters/analysis.py new file mode 100644 index 0000000..69c7884 --- /dev/null +++ b/demos/ordering-matters/analysis.py @@ -0,0 +1,359 @@ +# ABOUTME: Quantifies whether inferred behavioral rules change when identical +# ABOUTME: trajectories are presented in forward, reversed, or shuffled order. + +"""Ordering Matters analysis. + +Given an inference CSV exported from `ordering-matters.nlogo`, this script: +1. Normalizes inferred rule text per ordering. +2. Computes pairwise rule similarity. +3. Produces an order-dependency score. +4. Optionally writes JSON summary and a plot. + +Expected CSV columns: +- ordering: forward | reversed | shuffled +- inferred_rule: free text +- confidence: optional numeric 0-100 + +Example: + python3 analysis.py results/sample-inference.csv \ + --plot results/rule-similarity.png \ + --json results/analysis-summary.json +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import re +from dataclasses import dataclass +from difflib import SequenceMatcher +from itertools import combinations +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +EXPECTED_ORDERINGS = ("forward", "reversed", "shuffled") +REQUIRED_COLUMNS = {"ordering", "inferred_rule"} + +STOP_WORDS = { + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "by", + "for", + "from", + "in", + "into", + "is", + "it", + "of", + "on", + "or", + "that", + "the", + "their", + "then", + "this", + "to", + "with", +} + + +@dataclass(frozen=True) +class InferenceRow: + ordering: str + inferred_rule: str + confidence: Optional[float] = None + + +@dataclass(frozen=True) +class PairwiseSimilarity: + left: str + right: str + jaccard: float + sequence: float + combined: float + + +def parse_confidence(value: str) -> Optional[float]: + if value is None: + return None + text = value.strip().replace("%", "") + if not text: + return None + try: + parsed = float(text) + except ValueError: + return None + if math.isnan(parsed): + return None + return parsed + + +def load_inference_data(path: str) -> List[InferenceRow]: + csv_path = Path(path) + if not csv_path.exists(): + raise FileNotFoundError(f"No such file: {path}") + + with csv_path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + if reader.fieldnames is None: + raise ValueError("CSV has no header row") + + missing = REQUIRED_COLUMNS - set(reader.fieldnames) + if missing: + raise ValueError(f"Missing required columns: {sorted(missing)}") + + rows: List[InferenceRow] = [] + for raw in reader: + ordering = (raw.get("ordering") or "").strip().lower() + rule = (raw.get("inferred_rule") or "").strip() + confidence = parse_confidence(raw.get("confidence") or "") + if not ordering or not rule: + continue + rows.append(InferenceRow(ordering=ordering, inferred_rule=rule, confidence=confidence)) + + if not rows: + raise ValueError("CSV contains no usable inference rows") + + return dedupe_by_ordering(rows) + + +def dedupe_by_ordering(rows: Sequence[InferenceRow]) -> List[InferenceRow]: + """Keep only the first row per ordering to make comparison deterministic.""" + seen: Dict[str, InferenceRow] = {} + for row in rows: + if row.ordering not in seen: + seen[row.ordering] = row + + preferred = [o for o in EXPECTED_ORDERINGS if o in seen] + extras = sorted(o for o in seen if o not in EXPECTED_ORDERINGS) + ordered = preferred + extras + return [seen[o] for o in ordered] + + +def normalize_rule_text(text: str) -> str: + cleaned = re.sub(r"[^a-z0-9\s]", " ", text.lower()) + tokens = [tok for tok in cleaned.split() if tok and tok not in STOP_WORDS] + return " ".join(tokens) + + +def tokenize_rule(text: str) -> List[str]: + norm = normalize_rule_text(text) + if not norm: + return [] + return norm.split() + + +def jaccard_similarity(left_tokens: Iterable[str], right_tokens: Iterable[str]) -> float: + left_set = set(left_tokens) + right_set = set(right_tokens) + if not left_set and not right_set: + return 1.0 + union = left_set | right_set + if not union: + return 1.0 + return len(left_set & right_set) / len(union) + + +def sequence_similarity(left_text: str, right_text: str) -> float: + return SequenceMatcher(a=left_text, b=right_text).ratio() + + +def compute_pairwise_similarity(rows: Sequence[InferenceRow]) -> List[PairwiseSimilarity]: + similarities: List[PairwiseSimilarity] = [] + by_ordering = {r.ordering: r for r in rows} + + for left, right in combinations(by_ordering.keys(), 2): + left_norm = normalize_rule_text(by_ordering[left].inferred_rule) + right_norm = normalize_rule_text(by_ordering[right].inferred_rule) + jac = jaccard_similarity(tokenize_rule(left_norm), tokenize_rule(right_norm)) + seq = sequence_similarity(left_norm, right_norm) + similarities.append( + PairwiseSimilarity( + left=left, + right=right, + jaccard=jac, + sequence=seq, + combined=(jac + seq) / 2.0, + ) + ) + + return similarities + + +def similarity_matrix(rows: Sequence[InferenceRow], pairwise: Sequence[PairwiseSimilarity]) -> Dict[str, Dict[str, float]]: + labels = [r.ordering for r in rows] + matrix: Dict[str, Dict[str, float]] = {a: {b: 0.0 for b in labels} for a in labels} + + for label in labels: + matrix[label][label] = 1.0 + + for result in pairwise: + matrix[result.left][result.right] = result.combined + matrix[result.right][result.left] = result.combined + + return matrix + + +def confidence_by_ordering(rows: Sequence[InferenceRow]) -> Dict[str, Optional[float]]: + return {row.ordering: row.confidence for row in rows} + + +def analyze_dependency(rows: Sequence[InferenceRow], threshold: float = 0.35) -> Dict[str, object]: + pairwise = compute_pairwise_similarity(rows) + matrix = similarity_matrix(rows, pairwise) + + avg_similarity = sum(p.combined for p in pairwise) / len(pairwise) if pairwise else 1.0 + dependency_score = 1.0 - avg_similarity + + per_order_mean = {} + for label, peers in matrix.items(): + vals = [v for peer, v in peers.items() if peer != label] + per_order_mean[label] = sum(vals) / len(vals) if vals else 1.0 + + most_divergent = min(per_order_mean, key=per_order_mean.get) if per_order_mean else None + + summary = { + "orderings": [r.ordering for r in rows], + "pairwise": [ + { + "left": p.left, + "right": p.right, + "jaccard": round(p.jaccard, 4), + "sequence": round(p.sequence, 4), + "combined": round(p.combined, 4), + } + for p in pairwise + ], + "similarity_matrix": { + left: {right: round(value, 4) for right, value in row.items()} + for left, row in matrix.items() + }, + "average_similarity": round(avg_similarity, 4), + "order_dependency_score": round(dependency_score, 4), + "threshold": threshold, + "is_order_sensitive": dependency_score >= threshold, + "most_divergent_ordering": most_divergent, + "confidences": confidence_by_ordering(rows), + } + return summary + + +def generate_report(rows: Sequence[InferenceRow], summary: Dict[str, object]) -> str: + by_order = {r.ordering: r for r in rows} + lines = [] + lines.append("=" * 70) + lines.append("Ordering Matters: Rule Inference Divergence Report") + lines.append("=" * 70) + lines.append("") + + for ordering in summary["orderings"]: + row = by_order[ordering] + conf = row.confidence + conf_txt = "n/a" if conf is None else f"{conf:.2f}" + lines.append(f"[{ordering}] confidence={conf_txt}") + lines.append(f" raw rule: {row.inferred_rule}") + lines.append(f" normalized: {normalize_rule_text(row.inferred_rule)}") + lines.append("") + + lines.append("Pairwise similarities (combined = mean(jaccard, sequence)):") + for pair in summary["pairwise"]: + lines.append( + " " + f"{pair['left']} vs {pair['right']}: " + f"jaccard={pair['jaccard']:.4f}, " + f"sequence={pair['sequence']:.4f}, " + f"combined={pair['combined']:.4f}" + ) + + lines.append("") + lines.append(f"Average similarity: {summary['average_similarity']:.4f}") + lines.append(f"Order dependency score: {summary['order_dependency_score']:.4f}") + lines.append(f"Threshold: {summary['threshold']:.2f}") + lines.append(f"Order-sensitive: {summary['is_order_sensitive']}") + lines.append(f"Most divergent ordering: {summary['most_divergent_ordering']}") + lines.append("=" * 70) + return "\n".join(lines) + + +def plot_similarity(rows: Sequence[InferenceRow], summary: Dict[str, object], output_path: str) -> None: + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + except Exception as exc: # pragma: no cover + raise RuntimeError("matplotlib is required for --plot") from exc + + labels = summary["orderings"] + matrix = summary["similarity_matrix"] + heat_data = [[matrix[left][right] for right in labels] for left in labels] + + confidences = summary["confidences"] + conf_values = [(confidences.get(label) or 0.0) for label in labels] + + fig, axes = plt.subplots(1, 2, figsize=(11, 4.5)) + + ax = axes[0] + im = ax.imshow(heat_data, vmin=0.0, vmax=1.0, cmap="viridis") + ax.set_xticks(range(len(labels)), labels) + ax.set_yticks(range(len(labels)), labels) + ax.set_title("Rule Similarity Matrix") + + for i in range(len(labels)): + for j in range(len(labels)): + value = heat_data[i][j] + ax.text(j, i, f"{value:.2f}", ha="center", va="center", color="white" if value < 0.5 else "black") + + cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + cbar.set_label("Similarity") + + ax2 = axes[1] + bars = ax2.bar(labels, conf_values, color=["#1f77b4", "#ff7f0e", "#2ca02c"]) + ax2.set_title("LLM Confidence by Ordering") + ax2.set_ylabel("Confidence") + ax2.set_ylim(0, max(100, max(conf_values) * 1.15 if conf_values else 100)) + for bar, value in zip(bars, conf_values): + ax2.text(bar.get_x() + bar.get_width() / 2, value + 1, f"{value:.1f}", ha="center", va="bottom", fontsize=9) + + fig.tight_layout() + fig.savefig(output_path, dpi=150) + plt.close(fig) + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Analyze ordering-sensitivity of inferred rules.") + parser.add_argument("csv_file", help="CSV with ordering/inferred_rule/confidence columns") + parser.add_argument("--threshold", type=float, default=0.35, help="order dependency threshold (default: 0.35)") + parser.add_argument("--json", metavar="FILE", help="write machine-readable summary JSON") + parser.add_argument("--plot", metavar="FILE", help="write similarity/confidence plot PNG") + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + + rows = load_inference_data(args.csv_file) + summary = analyze_dependency(rows, threshold=args.threshold) + report = generate_report(rows, summary) + print(report) + + if args.json: + with open(args.json, "w", encoding="utf-8") as handle: + json.dump(summary, handle, indent=2) + print(f"JSON summary written to {args.json}") + + if args.plot: + plot_similarity(rows, summary, args.plot) + print(f"Plot written to {args.plot}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/demos/ordering-matters/config.txt b/demos/ordering-matters/config.txt new file mode 100644 index 0000000..e265774 --- /dev/null +++ b/demos/ordering-matters/config.txt @@ -0,0 +1,34 @@ +# Ordering Matters demo LLM config +# Use exactly one provider block. + +# --- OpenAI --- +provider=openai +api_key=YOUR_OPENAI_API_KEY_HERE +model=gpt-4o-mini +temperature=0.2 +max_tokens=400 +timeout_seconds=45 + +# --- Anthropic --- +#provider=anthropic +#api_key=YOUR_ANTHROPIC_API_KEY_HERE +#model=claude-3-5-sonnet-20241022 +#temperature=0.2 +#max_tokens=400 +#timeout_seconds=45 + +# --- Gemini --- +#provider=gemini +#api_key=YOUR_GEMINI_API_KEY_HERE +#model=gemini-1.5-flash +#temperature=0.2 +#max_tokens=400 +#timeout_seconds=45 + +# --- Ollama (local) --- +#provider=ollama +#model=llama3.2:latest +#base_url=http://localhost:11434 +#temperature=0.2 +#max_tokens=400 +#timeout_seconds=90 diff --git a/demos/ordering-matters/ordering-matters.nlogo b/demos/ordering-matters/ordering-matters.nlogo new file mode 100644 index 0000000..5b7f241 --- /dev/null +++ b/demos/ordering-matters/ordering-matters.nlogo @@ -0,0 +1,828 @@ +;; ABOUTME: Tests whether LLM rule learning depends on trajectory ordering. +;; ABOUTME: The same agent trajectories are presented as forward, reversed, and shuffled. + +extensions [llm] + +globals [ + ;; UI-linked metrics (kept names to match existing widgets) + group-a-food ;; forward ordering confidence (% if parsed) + group-b-food ;; reversed ordering confidence (% if parsed) + group-c-food ;; shuffled ordering confidence (% if parsed) + + food-collected-total ;; total food collected by all agents + run-label ;; short id for export files + + trajectory-log ;; list of [tick who x y heading energy food-total] + trajectory-forward ;; trajectory text in chronological order + trajectory-reversed ;; trajectory text reversed in time + trajectory-shuffled ;; trajectory text randomly shuffled + + inferred-forward ;; LLM inference text from forward trajectory order + inferred-reversed ;; LLM inference text from reversed trajectory order + inferred-shuffled ;; LLM inference text from shuffled trajectory order + + sample-size ;; max number of trajectory rows sent to prompt +] + +turtles-own [ + agent-energy + food-collected +] + +patches-own [ + has-food? +] + +;; ----------------------------------------------------------------------------- +;; Setup / Simulation +;; ----------------------------------------------------------------------------- + +to setup + clear-all + + set run-label (word "run-" (100000 + random 900000)) + set sample-size 240 + + set group-a-food 0 + set group-b-food 0 + set group-c-food 0 + set food-collected-total 0 + + set trajectory-log [] + set trajectory-forward "" + set trajectory-reversed "" + set trajectory-shuffled "" + set inferred-forward "" + set inferred-reversed "" + set inferred-shuffled "" + + ask patches [ + set has-food? false + set pcolor black + ] + + seed-food + + create-turtles num-agents [ + set shape "circle" + set color orange + 2 + set size 1.1 + setxy random-xcor random-ycor + set heading random 360 + set agent-energy 100 + set food-collected 0 + ] + + if use-llm? [ + load-llm-config + ] + + reset-ticks +end + +to go + ask turtles [ + hidden-behavior + try-collect-food + ] + + set food-collected-total sum [food-collected] of turtles + + if respawn-food? and ticks > 0 and ticks mod respawn-interval = 0 [ + respawn-food + ] + + record-trajectories + tick +end + +to hidden-behavior + ;; Hidden policy that we want the LLM to infer from observed motion: + ;; 1) seek nearby food, 2) avoid crowding, 3) keep momentum with mild noise. + + let nearby-food patches in-radius sensor-range with [has-food?] + if any? nearby-food [ + face min-one-of nearby-food [distance myself] + ] + + let crowd other turtles in-radius max (list 1 (comm-range / 2)) + if any? crowd [ + rt 25 + random 20 + ] + + rt (random-float 16) - 8 + fd speed + + set agent-energy max (list 0 (agent-energy - (0.5 + speed * 0.4))) + + if agent-energy <= 0 [ + setxy random-xcor random-ycor + set heading random 360 + set agent-energy 45 + ] +end + +to try-collect-food + if [has-food?] of patch-here [ + set food-collected food-collected + 1 + ask patch-here [ + set has-food? false + set pcolor black + ] + ] +end + +to seed-food + ask n-of food-count patches [ + set has-food? true + set pcolor green + 1 + ] +end + +to respawn-food + let candidates patches with [not has-food?] + if any? candidates [ + ask n-of min (list food-count count candidates) candidates [ + set has-food? true + set pcolor green + 1 + ] + ] +end + +to record-trajectories + foreach sort turtles [ t -> + ask t [ + set trajectory-log lput + (list ticks who precision xcor 3 precision ycor 3 precision heading 2 precision agent-energy 2 food-collected) + trajectory-log + ] + ] +end + +;; ----------------------------------------------------------------------------- +;; Ordering Construction + Inference +;; ----------------------------------------------------------------------------- + +to infer-rules + if ticks = 0 [ + output-print "Run the simulation first (click go-forever for ~100 ticks)." + stop + ] + + build-trajectory-orderings + + if not use-llm? [ + set inferred-forward "LLM disabled. Hidden rule: seek food, avoid crowding, preserve momentum." + set inferred-reversed inferred-forward + set inferred-shuffled inferred-forward + set group-a-food 0 + set group-b-food 0 + set group-c-food 0 + output-print "LLM disabled. Generated deterministic baseline text instead." + stop + ] + + let template-path resolve-template-path + + set inferred-forward run-inference "forward" trajectory-forward template-path + set inferred-reversed run-inference "reversed" trajectory-reversed template-path + set inferred-shuffled run-inference "shuffled" trajectory-shuffled template-path + + set group-a-food extract-confidence inferred-forward + set group-b-food extract-confidence inferred-reversed + set group-c-food extract-confidence inferred-shuffled + + output-print "=== Rule Inference By Ordering ===" + output-print (word "FORWARD : " inferred-forward) + output-print (word "REVERSED : " inferred-reversed) + output-print (word "SHUFFLED : " inferred-shuffled) +end + +to build-trajectory-orderings + if empty? trajectory-log [ + set trajectory-forward "" + set trajectory-reversed "" + set trajectory-shuffled "" + stop + ] + + let forward sort-by + [[r1 r2] -> + ifelse-value (item 0 r1 = item 0 r2) + [item 1 r1 < item 1 r2] + [item 0 r1 < item 0 r2] + ] + trajectory-log + + let reversed reverse forward + + random-seed 1776 + let shuffled shuffle forward + + set trajectory-forward format-trajectory forward + set trajectory-reversed format-trajectory reversed + set trajectory-shuffled format-trajectory shuffled +end + +to-report format-trajectory [rows] + let cap min (list sample-size length rows) + let clipped sublist rows 0 cap + + let lines map [row -> + (word + "tick=" item 0 row + " id=" item 1 row + " x=" item 2 row + " y=" item 3 row + " heading=" item 4 row + " energy=" item 5 row + " food=" item 6 row) + ] clipped + + report join-lines lines +end + +to-report join-lines [lines] + if empty? lines [ report "" ] + let out item 0 lines + foreach but-first lines [line -> + set out (word out "\n" line) + ] + report out +end + +to-report run-inference [ordering-label trajectory-text template-path] + let params (list + (list "ordering_label" ordering-label) + (list "tick_count" (word ticks)) + (list "agent_count" (word count turtles)) + (list "food_count" (word food-count)) + (list "trajectory_text" trajectory-text) + ) + + let response "" + carefully [ + set response llm:chat-with-template template-path params + ] [ + set response (word "Inference failed: " error-message) + ] + report response +end + +;; ----------------------------------------------------------------------------- +;; Export + Utility +;; ----------------------------------------------------------------------------- + +to export-data + build-trajectory-orderings + export-trajectory-csv + export-inference-csv +end + +to export-trajectory-csv + let filename (word "results/" run-label "-trajectories.csv") + + carefully [ + file-open filename + file-print "ordering,tick,agent_id,x,y,heading,energy,food_collected" + + write-ordering-rows "forward" trajectory-forward + write-ordering-rows "reversed" trajectory-reversed + write-ordering-rows "shuffled" trajectory-shuffled + + file-close + output-print (word "Exported trajectories to " filename) + ] [ + output-print (word "Trajectory export failed: " error-message) + carefully [ file-close ] [ ] + ] +end + +to write-ordering-rows [ordering text-block] + if text-block = "" [ stop ] + let lines split-lines text-block + foreach lines [line -> + let tick-value parse-number-after "tick=" line + let id-value parse-number-after "id=" line + let x-value parse-number-after "x=" line + let y-value parse-number-after "y=" line + let h-value parse-number-after "heading=" line + let e-value parse-number-after "energy=" line + let f-value parse-number-after "food=" line + file-print (word ordering "," tick-value "," id-value "," x-value "," y-value "," h-value "," e-value "," f-value) + ] +end + +to export-inference-csv + let filename (word "results/" run-label "-inference.csv") + + carefully [ + file-open filename + file-print "ordering,inferred_rule,confidence" + file-print (word "forward,\"" sanitize-csv inferred-forward "\"," group-a-food) + file-print (word "reversed,\"" sanitize-csv inferred-reversed "\"," group-b-food) + file-print (word "shuffled,\"" sanitize-csv inferred-shuffled "\"," group-c-food) + file-close + output-print (word "Exported inferences to " filename) + ] [ + output-print (word "Inference export failed: " error-message) + carefully [ file-close ] [ ] + ] +end + +to load-llm-config + ifelse file-exists? "config.txt" [ + llm:load-config "config.txt" + ] [ + llm:load-config "demos/ordering-matters/config.txt" + ] +end + +to-report resolve-template-path + ifelse file-exists? "rule-inference-template.yaml" [ + report "rule-inference-template.yaml" + ] [ + report "demos/ordering-matters/rule-inference-template.yaml" + ] +end + +to-report extract-confidence [text] + ;; Attempts to parse a confidence value from model output. + ;; Accepts either 0-1 values or percentages. + let nums extract-all-numbers text + if empty? nums [ report 0 ] + + let best 0 + foreach nums [n -> + if n > 0 and n <= 1 [ set best max (list best (n * 100)) ] + if n > 1 and n <= 100 [ set best max (list best n) ] + ] + report precision best 2 +end + +to-report sanitize-csv [text] + report replace-all text "\"" "''" +end + +to-report split-lines [text] + if text = "" [ report [] ] + + let lines [] + let start 0 + let i 0 + while [i < length text] [ + if substring text i (i + 1) = "\n" [ + set lines lput (substring text start i) lines + set start i + 1 + ] + set i i + 1 + ] + + if start <= length text [ + set lines lput (substring text start length text) lines + ] + + report filter [line -> line != ""] lines +end + +to-report parse-number-after [token line] + let p position token line + if p = false [ report 0 ] + + let start p + length token + let finish start + + while [finish < length line and not member? (substring line finish (finish + 1)) [" " ","]] [ + set finish finish + 1 + ] + + let s substring line start finish + if s = "" [ report 0 ] + report read-from-string s +end + +to-report extract-all-numbers [text] + let nums [] + let i 0 + while [i < length text] [ + let ch substring text i (i + 1) + if member? ch ["0" "1" "2" "3" "4" "5" "6" "7" "8" "9"] [ + let start i + let j i + while [j < length text and member? (substring text j (j + 1)) ["0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "."]] [ + set j j + 1 + ] + set nums lput (read-from-string (substring text start j)) nums + set i j + ] + set i i + 1 + ] + report nums +end + +to-report replace-all [text target replacement] + if text = "" [ report "" ] + let p position target text + if p = false [ report text ] + report (word + (substring text 0 p) + replacement + (replace-all (substring text (p + length target) (length text)) target replacement)) +end + +;; Reporter retained for existing BehaviorSpace metric entries. +to-report safe-mean [ordering-code] + if ordering-code = "A" [ report group-a-food ] + if ordering-code = "B" [ report group-b-food ] + if ordering-code = "C" [ report group-c-food ] + report 0 +end +@#$#@#$#@ +GRAPHICS-WINDOW +380 +10 +818 +449 +-1 +-1 +13.0 +1 +10 +1 +1 +1 +0 +1 +1 +1 +-16 +16 +-16 +16 +1 +1 +1 +ticks +30.0 + +BUTTON +10 +45 +80 +78 +NIL +setup +NIL +1 +T +OBSERVER +NIL +NIL +NIL +NIL +1 + +BUTTON +90 +45 +155 +78 +go +go +NIL +1 +T +OBSERVER +NIL +NIL +NIL +NIL +1 + +BUTTON +165 +45 +255 +78 +go-forever +go +T +1 +T +OBSERVER +NIL +NIL +NIL +NIL +1 + +SLIDER +10 +10 +180 +43 +num-agents +num-agents +3 +60 +18.0 +3 +1 +NIL +HORIZONTAL + +SLIDER +190 +10 +370 +43 +food-count +food-count +10 +200 +80.0 +10 +1 +NIL +HORIZONTAL + +SLIDER +10 +85 +180 +118 +sensor-range +sensor-range +1 +10 +4.0 +1 +1 +NIL +HORIZONTAL + +SLIDER +190 +85 +370 +118 +speed +speed +0.5 +3 +1.0 +0.5 +1 +NIL +HORIZONTAL + +SLIDER +10 +125 +180 +158 +comm-range +comm-range +1 +15 +6.0 +1 +1 +NIL +HORIZONTAL + +SLIDER +190 +125 +370 +158 +share-interval +share-interval +1 +20 +5.0 +1 +1 +ticks +HORIZONTAL + +SWITCH +10 +165 +130 +198 +use-llm? +use-llm? +1 +1 +-1000 + +SWITCH +140 +165 +280 +198 +respawn-food? +respawn-food? +0 +1 +-1000 + +SLIDER +10 +205 +180 +238 +respawn-interval +respawn-interval +5 +50 +20.0 +5 +1 +ticks +HORIZONTAL + +MONITOR +10 +250 +130 +295 +forward conf +group-a-food +2 +1 +11 + +MONITOR +140 +250 +260 +295 +reversed conf +group-b-food +2 +1 +11 + +MONITOR +270 +250 +370 +295 +shuffled conf +group-c-food +2 +1 +11 + +BUTTON +10 +305 +130 +338 +export-data +export-data +NIL +1 +T +OBSERVER +NIL +NIL +NIL +NIL +1 + +BUTTON +140 +305 +260 +338 +infer-rules +infer-rules +NIL +1 +T +OBSERVER +NIL +NIL +NIL +NIL +1 + +PLOT +10 +345 +370 +520 +Inference Confidence by Ordering +tick +confidence +0.0 +10.0 +0.0 +10.0 +true +true +"" "" +PENS +"forward" 1.0 0 -2674135 true "" "plot group-a-food" +"reversed" 1.0 0 -13345367 true "" "plot group-b-food" +"shuffled" 1.0 0 -8732573 true "" "plot group-c-food" + +OUTPUT +10 +530 +370 +670 +12 + +@#$#@#$#@ +## WHAT IS IT? + +This demo tests whether a language model learns different behavioral rules from the **same trajectory data** when the rows are presented in different orders: + +- forward (chronological) +- reversed (time reversed) +- shuffled (random permutation) + +## HOW IT WORKS + +1. Agents follow one hidden policy while foraging: +- seek nearby food +- avoid local crowding +- preserve momentum with small heading noise + +2. The model records trajectories at each tick. + +3. `infer-rules` serializes the trajectory log into three orderings and prompts an LLM with the same template for each ordering. + +4. `export-data` writes: +- `results/-trajectories.csv` +- `results/-inference.csv` + +## HOW TO USE IT + +1. Configure `config.txt` (or disable `use-llm?`) +2. Click `setup` +3. Run `go-forever` for 100-200 ticks +4. Click `infer-rules` +5. Click `export-data` +6. Analyze exports with `analysis.py` + +## WHY IT MATTERS + +If inferred rules differ strongly between forward/reversed/shuffled views, rule learning is order-sensitive. This is a practical failure mode when using LLMs for inverse behavior modeling. + +## CREDITS + +Part of the AgentSwarm NetLogo LLM demos. +@#$#@#$#@ +default +true +0 +Polygon -7500403 true true 150 5 40 250 150 205 260 250 + +circle +false +0 +Circle -7500403 true true 0 0 300 + +square +false +0 +Rectangle -7500403 true true 30 30 270 270 + +triangle +false +0 +Polygon -7500403 true true 150 30 15 255 285 255 +@#$#@#$#@ +NetLogo 6.4.0 +@#$#@#$#@ +@#$#@#$#@ +@#$#@#$#@ + + +setup +repeat 150 [go] +group-a-food +group-b-food +group-c-food +food-collected-total + + +@#$#@#$#@ +@#$#@#$#@ +default +0.0 +-0.2 0 0.0 1.0 +0.0 1 1.0 0.0 +0.2 0 0.0 1.0 +link direction +true +0 +Line -7500403 true 0 150 0 0 150 300 +@#$#@#$#@ +1 +@#$#@#$#@ diff --git a/demos/ordering-matters/results/analysis-summary.json b/demos/ordering-matters/results/analysis-summary.json new file mode 100644 index 0000000..f5d6d01 --- /dev/null +++ b/demos/ordering-matters/results/analysis-summary.json @@ -0,0 +1,57 @@ +{ + "orderings": [ + "forward", + "reversed", + "shuffled" + ], + "pairwise": [ + { + "left": "forward", + "right": "reversed", + "jaccard": 0.0333, + "sequence": 0.3406, + "combined": 0.187 + }, + { + "left": "forward", + "right": "shuffled", + "jaccard": 0.0769, + "sequence": 0.2899, + "combined": 0.1834 + }, + { + "left": "reversed", + "right": "shuffled", + "jaccard": 0.0, + "sequence": 0.3426, + "combined": 0.1713 + } + ], + "similarity_matrix": { + "forward": { + "forward": 1.0, + "reversed": 0.187, + "shuffled": 0.1834 + }, + "reversed": { + "forward": 0.187, + "reversed": 1.0, + "shuffled": 0.1713 + }, + "shuffled": { + "forward": 0.1834, + "reversed": 0.1713, + "shuffled": 1.0 + } + }, + "average_similarity": 0.1806, + "order_dependency_score": 0.8194, + "threshold": 0.35, + "is_order_sensitive": true, + "most_divergent_ordering": "shuffled", + "confidences": { + "forward": 87.0, + "reversed": 63.0, + "shuffled": 55.0 + } +} \ No newline at end of file diff --git a/demos/ordering-matters/results/rule-similarity.png b/demos/ordering-matters/results/rule-similarity.png new file mode 100644 index 0000000..6df7d65 Binary files /dev/null and b/demos/ordering-matters/results/rule-similarity.png differ diff --git a/demos/ordering-matters/results/sample-inference.csv b/demos/ordering-matters/results/sample-inference.csv new file mode 100644 index 0000000..77f06e8 --- /dev/null +++ b/demos/ordering-matters/results/sample-inference.csv @@ -0,0 +1,4 @@ +ordering,inferred_rule,confidence +forward,"Agents prioritize nearby food seeking, deviate when local crowding is high, then stabilize heading with mild stochastic drift.",87 +reversed,"Agents appear to wander first and only later reorient toward goals, suggesting delayed correction and weaker immediate goal pursuit.",63 +shuffled,"Policy seems mostly noisy with intermittent short-range attraction to food and occasional anti-crowding turns.",55 diff --git a/demos/ordering-matters/results/sample-trajectories.csv b/demos/ordering-matters/results/sample-trajectories.csv new file mode 100644 index 0000000..76de75b --- /dev/null +++ b/demos/ordering-matters/results/sample-trajectories.csv @@ -0,0 +1,10 @@ +ordering,tick,agent_id,x,y,heading,energy,food_collected +forward,0,0,-3.1,1.2,44.0,99.2,0 +forward,1,0,-2.2,1.9,41.6,98.3,0 +forward,2,0,-1.0,2.4,37.8,97.5,1 +reversed,2,0,-1.0,2.4,37.8,97.5,1 +reversed,1,0,-2.2,1.9,41.6,98.3,0 +reversed,0,0,-3.1,1.2,44.0,99.2,0 +shuffled,1,0,-2.2,1.9,41.6,98.3,0 +shuffled,0,0,-3.1,1.2,44.0,99.2,0 +shuffled,2,0,-1.0,2.4,37.8,97.5,1 diff --git a/demos/ordering-matters/rule-inference-template.yaml b/demos/ordering-matters/rule-inference-template.yaml new file mode 100644 index 0000000..cddde51 --- /dev/null +++ b/demos/ordering-matters/rule-inference-template.yaml @@ -0,0 +1,26 @@ +system: | + You are an expert in inverse behavior modeling. + Infer an agent policy from observed trajectories. + +template: | + You are given trajectory rows from a 2D foraging simulation. + + Ordering label: {ordering_label} + Ticks observed: {tick_count} + Agent count: {agent_count} + Initial food patches: {food_count} + + Trajectory rows: + {trajectory_text} + + Task: + Infer the most likely behavioral rule set that generated these trajectories. + + Return exactly these fields: + - inferred_rule: short plain-English rule (1-2 sentences) + - evidence: 2-4 bullet points tied to trajectory patterns + - confidence: a number from 0 to 100 + + Constraints: + - Do not mention ordering artifacts unless needed as uncertainty. + - Focus on motion regularities (goal-seeking, avoidance, momentum, randomness). diff --git a/demos/ordering-matters/tests/__init__.py b/demos/ordering-matters/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demos/ordering-matters/tests/test_analysis.py b/demos/ordering-matters/tests/test_analysis.py new file mode 100644 index 0000000..af99ab1 --- /dev/null +++ b/demos/ordering-matters/tests/test_analysis.py @@ -0,0 +1,116 @@ +# ABOUTME: Unit tests for ordering-matters rule-divergence analysis. +# ABOUTME: Validates loading, normalization, similarity scoring, and outputs. + +from __future__ import annotations + +import csv +import json +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import analysis + + +@pytest.fixture +def inference_csv(tmp_path: Path) -> Path: + path = tmp_path / "inference.csv" + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["ordering", "inferred_rule", "confidence"]) + writer.writerow(["forward", "Agents seek food, avoid crowding, and maintain heading.", "88"]) + writer.writerow(["reversed", "Agents wander first, then correct toward goals late.", "61"]) + writer.writerow(["shuffled", "Movement appears noisy with weak local goal-seeking.", "54"]) + return path + + +def test_load_inference_data_reads_rows(inference_csv: Path) -> None: + rows = analysis.load_inference_data(str(inference_csv)) + assert [r.ordering for r in rows] == ["forward", "reversed", "shuffled"] + assert rows[0].confidence == pytest.approx(88.0) + + +def test_load_inference_data_missing_columns(tmp_path: Path) -> None: + path = tmp_path / "bad.csv" + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["ordering", "note"]) + writer.writerow(["forward", "x"]) + + with pytest.raises(ValueError, match="Missing required columns"): + analysis.load_inference_data(str(path)) + + +def test_dedupe_by_ordering_keeps_first() -> None: + rows = [ + analysis.InferenceRow("forward", "first forward", 50), + analysis.InferenceRow("forward", "second forward", 99), + analysis.InferenceRow("reversed", "rev", 30), + ] + deduped = analysis.dedupe_by_ordering(rows) + assert len(deduped) == 2 + assert deduped[0].inferred_rule == "first forward" + + +def test_normalize_rule_text_removes_noise() -> None: + value = analysis.normalize_rule_text("The agents, in a line, move TO food!") + assert value == "agents line move food" + + +def test_similarity_scores_are_bounded(inference_csv: Path) -> None: + rows = analysis.load_inference_data(str(inference_csv)) + pairwise = analysis.compute_pairwise_similarity(rows) + assert len(pairwise) == 3 + for pair in pairwise: + assert 0.0 <= pair.jaccard <= 1.0 + assert 0.0 <= pair.sequence <= 1.0 + assert 0.0 <= pair.combined <= 1.0 + + +def test_analyze_dependency_detects_order_sensitivity(inference_csv: Path) -> None: + rows = analysis.load_inference_data(str(inference_csv)) + summary = analysis.analyze_dependency(rows, threshold=0.2) + assert summary["is_order_sensitive"] is True + assert summary["order_dependency_score"] > 0 + + +def test_analyze_dependency_not_sensitive_when_same_rule() -> None: + rows = [ + analysis.InferenceRow("forward", "seek food avoid crowding", 70), + analysis.InferenceRow("reversed", "seek food avoid crowding", 72), + analysis.InferenceRow("shuffled", "seek food avoid crowding", 68), + ] + summary = analysis.analyze_dependency(rows, threshold=0.2) + assert summary["is_order_sensitive"] is False + assert summary["order_dependency_score"] == pytest.approx(0.0) + + +def test_generate_report_contains_key_fields(inference_csv: Path) -> None: + rows = analysis.load_inference_data(str(inference_csv)) + summary = analysis.analyze_dependency(rows) + report = analysis.generate_report(rows, summary) + assert "Ordering Matters" in report + assert "Order dependency score" in report + assert "forward" in report + + +def test_main_writes_json(inference_csv: Path, tmp_path: Path) -> None: + out_json = tmp_path / "summary.json" + exit_code = analysis.main([str(inference_csv), "--json", str(out_json)]) + assert exit_code == 0 + payload = json.loads(out_json.read_text(encoding="utf-8")) + assert "order_dependency_score" in payload + assert payload["orderings"] == ["forward", "reversed", "shuffled"] + + +def test_plot_similarity_smoke(inference_csv: Path, tmp_path: Path) -> None: + pytest.importorskip("matplotlib") + rows = analysis.load_inference_data(str(inference_csv)) + summary = analysis.analyze_dependency(rows) + plot_path = tmp_path / "plot.png" + analysis.plot_similarity(rows, summary, str(plot_path)) + assert plot_path.exists() + assert plot_path.stat().st_size > 0