podstack commited on
Commit
f3680b0
·
verified ·
1 Parent(s): 7a74082

Model card: diagnostics of what Jedi relies on

Browse files
Files changed (3) hide show
  1. README.md +59 -8
  2. diagnostics.py +95 -0
  3. diagnostics_ladder.py +79 -0
README.md CHANGED
@@ -38,7 +38,12 @@ picks a model *before* any answer exists (see [How Jedi differs from Shaktimaan]
38
  - Within 1.4 points of a *perfect* verifier (93.3%) on the same ladder.
39
  - **AUROC 0.92** at telling correct answers from wrong ones on unseen tasks; calibrated confidence (ECE 0.017).
40
  - One forward pass and **one output token** per check: ~56 ms at batch size 1, ~450 input tokens.
41
- - **Weak outside its training domains**: AUROC 0.72 on BIG-Bench Hard and 0.59 on CommonsenseQA. See *Limitations*.
 
 
 
 
 
42
 
43
  ## How it works
44
 
@@ -76,6 +81,44 @@ picks a model *before* any answer exists (see [How Jedi differs from Shaktimaan]
76
  4. **The single dial.** The threshold trades cost for accuracy. Raise it to escalate more and catch more wrong
77
  answers; lower it to save money. The top rung is always accepted, so every task gets an answer.
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  ## Quick start
80
 
81
  ### Score one answer
@@ -101,9 +144,12 @@ def p_correct(task, answer, agent="glm-5.3-flash", kind="num", finish="stop"):
101
  return 1 / (1 + math.exp(-(cal["a"] * gap + cal["b"])))
102
 
103
  task = "A shop sells pencils at 3 for $1.20. How much do 15 pencils cost, in dollars?"
104
- print(p_correct(task, "Each pencil costs 1.20 / 3 = 0.40. 15 x 0.40 = 6.00.\nFinal answer: 6"))
105
- print(p_correct(task, "15 pencils is 15 / 3 = 5 groups. 5 x 1.20 = 5.00.\nFinal answer: 5"))
 
106
  ```
 
 
107
 
108
  Always build the input with `common.judge_view(...)` and use `common.JEDI_SYSTEM` as the system prompt; Jedi was
109
  trained on exactly that format. `kind` is the task type it was trained with: `num`, `math`, `mc`
@@ -151,9 +197,10 @@ problem, getting good answers without paying for the biggest model every time, a
151
 
152
  **Why the switch.** Shaktimaan's main finding was that, on ordinary text and code tasks, *which* mid-size model
153
  solves a given task is largely sampling noise that cannot be predicted from the prompt. A router has to guess
154
- blind. Jedi waits until a cheap answer exists and judges that answer: unsupported reasoning, arithmetic that doesn't
155
- add up, truncation, hedging and code that won't pass its tests. Its errors are mostly answers that look right but
156
- aren't, which a router cannot see at all.
 
157
 
158
  **Head to head**, on the same 491 held-out tasks with the same fresh agent samples:
159
 
@@ -261,8 +308,8 @@ Calibrated P(correct) on answers to tasks Jedi never saw. *False accept* = share
261
  | ARC-Challenge | 300 | 4.0% | 0.842 | 91.7% |
262
  | CommonsenseQA | 252 | 18.3% | 0.593 | 89.1% |
263
 
264
- Jedi is strongest where correctness can be checked from the answer itself: code, multi-step math and
265
- knowledge questions with worked reasoning. It is weakest where a wrong answer reads exactly like a right one,
266
  such as single-hop commonsense and easy science multiple choice.
267
 
268
  ### As the gate of the ladder
@@ -324,6 +371,9 @@ CommonsenseQA-heavy new-domain set, Jedi at 0.7 does not beat always using Qwen.
324
  single forward pass and batch well.
325
 
326
  ## Limitations
 
 
 
327
  - **Domain-bound.** Verification skill transfers poorly to unseen task families (BIG-Bench Hard AUROC 0.72) and is
328
  near chance on CommonsenseQA (0.59). Measure it on your own traffic before relying on a threshold.
329
  - **A third of wrong in-domain answers get through at 0.5.** False-accept rates are highest for the strongest
@@ -350,6 +400,7 @@ single forward pass and batch well.
350
  | `train.py` | fine-tuning script (stage 3) |
351
  | `evaluate.py` | verifier metrics, ladder, baselines and threshold sweep (stage 4) |
352
  | `eval_results.json` | raw evaluation output behind every number on this card |
 
353
 
354
  The graded answer data is not published; `gen.py` and `build.py` regenerate it given endpoints for the three models.
355
 
 
38
  - Within 1.4 points of a *perfect* verifier (93.3%) on the same ladder.
39
  - **AUROC 0.92** at telling correct answers from wrong ones on unseen tasks; calibrated confidence (ECE 0.017).
40
  - One forward pass and **one output token** per check: ~56 ms at batch size 1, ~450 input tokens.
41
+ - **It judges how an answer looks, not whether its final value is right.** Swapping the final answer of a real
42
+ response barely moves its score. Much of its signal is surface cues (length, truncation, which model answered),
43
+ though it still beats a gate built only on those cues by 1-2 points on the ladder. See
44
+ [What Jedi actually picks up on](#what-jedi-actually-picks-up-on).
45
+ - **Weak outside its training domains**: AUROC 0.72 on BIG-Bench Hard (where answer length alone scores 0.80) and
46
+ 0.59 on CommonsenseQA. See *Limitations*.
47
 
48
  ## How it works
49
 
 
81
  4. **The single dial.** The threshold trades cost for accuracy. Raise it to escalate more and catch more wrong
82
  answers; lower it to save money. The top rung is always accepted, so every task gets an answer.
83
 
84
+ ### What Jedi actually picks up on
85
+ The system prompt asks Jedi to look for broken reasoning, bad arithmetic, failing code, hedging and truncation.
86
+ Training only rewards predicting the grade, though, so we tested what it learned (`diagnostics.py`,
87
+ `diagnostics_ladder.py`).
88
+
89
+ | check | in-domain test | unseen (BIG-Bench Hard) |
90
+ |---|---|---|
91
+ | Jedi AUROC | **0.919** | 0.721 |
92
+ | surface-feature baseline (logistic regression on answer length, truncated, empty final answer, agent, source) | 0.871 | **0.826** |
93
+ | answer length alone (shorter = more likely right) | 0.810 | 0.801 |
94
+ | Jedi, truncated answers excluded | 0.879 | 0.682 |
95
+ | Jedi ranks the right answer above a wrong one *to the same task* | 93.1% of 690 pairs | 84.6% of 1,168 pairs |
96
+
97
+ **Final-answer swap test.** We took 1,583 real test answers (multiple choice and numeric, not truncated) and changed
98
+ only the value after `Final answer:`. Right answers got a wrong value; wrong answers got the gold value.
99
+
100
+ | | mean P(correct) before | after swap |
101
+ |---|---|---|
102
+ | right answers -> wrong final value (1,443) | 0.912 | 0.902 |
103
+ | wrong answers -> gold final value (140) | 0.717 | 0.806 |
104
+
105
+ Jedi barely notices when the final answer contradicts the reasoning above it, or when a wrong answer is given the
106
+ correct final value. It scores the *body* of the response: its length, its shape, whether it ends cleanly, and the
107
+ style of reasoning that tends to accompany wrong answers from each model. Hand-written short answers with a planted
108
+ arithmetic slip are usually accepted.
109
+
110
+ **On the ladder, Jedi still beats the surface-feature gate** (same 491 tasks, best settings):
111
+
112
+ | gate | accuracy | relative cost |
113
+ |---|---|---|
114
+ | surface-feature baseline, threshold 0.05 | 89.8% | 0.224 |
115
+ | surface-feature baseline, threshold 0.1 | 89.8% | 0.395 |
116
+ | Jedi, threshold 0.05 | 91.0% | 0.244 |
117
+ | Jedi, threshold 0.7 | 91.9% | 0.454 |
118
+
119
+ So in its training domains, Jedi learned something real beyond length and truncation. That gain is 1-2 points of
120
+ end-to-end accuracy. On unseen task families, the surface features transfer better than Jedi does.
121
+
122
  ## Quick start
123
 
124
  ### Score one answer
 
144
  return 1 / (1 + math.exp(-(cal["a"] * gap + cal["b"])))
145
 
146
  task = "A shop sells pencils at 3 for $1.20. How much do 15 pencils cost, in dollars?"
147
+ print(p_correct(task, "Each pencil costs 1.20 / 3 = 0.40. 15 x 0.40 = 6.00.\nFinal answer: 6")) # 0.99 accept
148
+ print(p_correct(task, "Let me think step by step. First, 3 pencils cost 1.20, so", finish="length")) # 0.07 escalate
149
+ print(p_correct(task, "15 / 3 = 5 groups. 5 x 1.20 = 5.00.\nFinal answer: 5")) # 0.98 - missed
150
  ```
151
+ The third call shows the main limitation: a fluent answer with an arithmetic slip is accepted. Jedi is a learned
152
+ filter over real model outputs, not a checker of the math.
153
 
154
  Always build the input with `common.judge_view(...)` and use `common.JEDI_SYSTEM` as the system prompt; Jedi was
155
  trained on exactly that format. `kind` is the task type it was trained with: `num`, `math`, `mc`
 
197
 
198
  **Why the switch.** Shaktimaan's main finding was that, on ordinary text and code tasks, *which* mid-size model
199
  solves a given task is largely sampling noise that cannot be predicted from the prompt. A router has to guess
200
+ blind. Jedi waits until a cheap answer exists and looks at that particular sample. Signs that a sample went wrong
201
+ (rambling length, truncation, the reasoning style that comes with mistakes) only exist after it is written, so a
202
+ router can never see them. Jedi does not re-derive the answer, so fluent answers that are wrong still get through
203
+ (see [What Jedi actually picks up on](#what-jedi-actually-picks-up-on)).
204
 
205
  **Head to head**, on the same 491 held-out tasks with the same fresh agent samples:
206
 
 
308
  | ARC-Challenge | 300 | 4.0% | 0.842 | 91.7% |
309
  | CommonsenseQA | 252 | 18.3% | 0.593 | 89.1% |
310
 
311
+ Jedi is strongest where wrong answers *look* different from right ones: multi-step math and code, where
312
+ failures tend to be long, meandering or cut off. It is weakest where a wrong answer reads exactly like a right one,
313
  such as single-hop commonsense and easy science multiple choice.
314
 
315
  ### As the gate of the ladder
 
371
  single forward pass and batch well.
372
 
373
  ## Limitations
374
+ - **Not a checker.** Jedi does not verify the final answer: changing it barely moves the score, and planted
375
+ arithmetic slips in otherwise fluent answers usually pass. It relies heavily on surface cues, and on unseen task
376
+ families a logistic regression on answer length, truncation and agent beats it (AUROC 0.83 vs 0.72).
377
  - **Domain-bound.** Verification skill transfers poorly to unseen task families (BIG-Bench Hard AUROC 0.72) and is
378
  near chance on CommonsenseQA (0.59). Measure it on your own traffic before relying on a threshold.
379
  - **A third of wrong in-domain answers get through at 0.5.** False-accept rates are highest for the strongest
 
400
  | `train.py` | fine-tuning script (stage 3) |
401
  | `evaluate.py` | verifier metrics, ladder, baselines and threshold sweep (stage 4) |
402
  | `eval_results.json` | raw evaluation output behind every number on this card |
403
+ | `diagnostics.py`, `diagnostics_ladder.py` | surface-feature baselines, answer-swap test, ladder with a surface-feature gate |
404
 
405
  The graded answer data is not published; `gen.py` and `build.py` regenerate it given endpoints for the three models.
406
 
diagnostics.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Does Jedi read the answer content, or surface cues? (1) shallow-feature baselines (2) final-answer swap test."""
2
+ import json, math, random, re, sys
3
+ import numpy as np, torch
4
+ import common
5
+ from datasets import load_from_disk
6
+ from transformers import AutoModelForCausalLM, AutoTokenizer
7
+
8
+ def roc_auc_score(y, p):
9
+ y, p = np.asarray(y, bool), np.asarray(p, float)
10
+ r = np.argsort(np.argsort(p, kind="mergesort")) + 1
11
+ return float((r[y].sum() - y.sum() * (y.sum() + 1) / 2) / (y.sum() * (~y).sum()))
12
+
13
+ class LogisticRegression:
14
+ def fit(self, X, y):
15
+ X = torch.tensor(X, dtype=torch.float32); y = torch.tensor(np.asarray(y, float), dtype=torch.float32)
16
+ self.mu, self.sd = X.mean(0), X.std(0) + 1e-6
17
+ X = (X - self.mu) / self.sd
18
+ self.w = torch.zeros(X.shape[1], requires_grad=True); self.b = torch.zeros(1, requires_grad=True)
19
+ opt = torch.optim.LBFGS([self.w, self.b], max_iter=500)
20
+ def closure():
21
+ opt.zero_grad(); l = torch.nn.functional.binary_cross_entropy_with_logits(X @ self.w + self.b, y); l.backward(); return l
22
+ opt.step(closure); return self
23
+ def predict_proba(self, X):
24
+ X = (torch.tensor(X, dtype=torch.float32) - self.mu) / self.sd
25
+ p = torch.sigmoid(X @ self.w + self.b).detach().numpy(); return np.stack([1 - p, p], 1)
26
+ random.seed(0)
27
+ P = sys.argv[1] if len(sys.argv) > 1 else "jedi-0.5b"
28
+ cal = json.load(open(P + "/calibration.json")); tok = AutoTokenizer.from_pretrained(P); tok.padding_side = "left"
29
+ model = AutoModelForCausalLM.from_pretrained(P, dtype=torch.bfloat16).cuda().eval()
30
+ ds = load_from_disk("data/jedi_judge")
31
+
32
+ def score(views, bs=16):
33
+ out = np.zeros(len(views)); order = np.argsort([len(v) for v in views])
34
+ for i in range(0, len(views), bs):
35
+ idx = order[i:i + bs]
36
+ msgs = [[{"role": "system", "content": common.JEDI_SYSTEM}, {"role": "user", "content": views[j]}] for j in idx]
37
+ enc = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt", padding=True, return_dict=True).to("cuda")
38
+ with torch.no_grad(): l = model(**enc).logits[:, -1].float()
39
+ g = (l[:, cal["accept_token"]] - l[:, cal["escalate_token"]]).cpu().numpy()
40
+ out[idx] = 1 / (1 + np.exp(-(cal["a"] * g + cal["b"])))
41
+ return out
42
+
43
+ def feats(d):
44
+ ag = sorted(set(ds["train"]["agent"])); src = sorted(set(ds["train"]["source"]) | set(ds["ood"]["source"]))
45
+ X = []
46
+ for r in d:
47
+ X.append([math.log1p(r["completion_tokens"]), r["finish"] == "length", not r["pred"].strip()]
48
+ + [r["agent"] == a for a in ag] + [r["source"] == s for s in src])
49
+ return np.array(X, float)
50
+
51
+ base = ds["train"].filter(lambda r: r["sample"] == 0) # one sample per (task, agent): no oversampling dupes in fit
52
+ lr = LogisticRegression().fit(feats(base), base["correct"])
53
+ res = {}
54
+ for sp in ("test", "ood"):
55
+ d = ds[sp]; y = np.array(d["correct"])
56
+ p = score(d["judge_input"])
57
+ trunc = np.array([f == "length" for f in d["finish"]])
58
+ res[sp] = dict(
59
+ jedi=roc_auc_score(y, p),
60
+ shallow_logreg=roc_auc_score(y, lr.predict_proba(feats(d))[:, 1]),
61
+ length_only=roc_auc_score(y, -np.array(d["completion_tokens"])),
62
+ jedi_not_truncated=roc_auc_score(y[~trunc], p[~trunc]),
63
+ truncated_share=float(trunc.mean()))
64
+ # within-task: tasks with both a right and a wrong answer -> does Jedi rank the right one higher?
65
+ by = {}
66
+ for i, r in enumerate(d):
67
+ by.setdefault(r["task_id"], []).append(i)
68
+ wins = tot = 0
69
+ for ids in by.values():
70
+ for i in ids:
71
+ for j in ids:
72
+ if y[i] and not y[j]:
73
+ tot += 1; wins += (p[i] > p[j]) + 0.5 * (p[i] == p[j])
74
+ res[sp]["within_task_pairwise_acc"] = wins / tot; res[sp]["within_task_pairs"] = tot
75
+ if sp == "test":
76
+ # swap test: replace the 'Final answer:' value. wrong -> gold, right -> a wrong value.
77
+ orig, swapped, ys = [], [], []
78
+ for r in d:
79
+ if r["kind"] not in ("mc", "num") or r["finish"] == "length": continue
80
+ m = list(re.finditer(r"(Final answer\s*[::]\s*)(.+)", r["judge_input"], flags=re.I))
81
+ if not m: continue
82
+ m = m[-1]; gold = r["gold"].strip()
83
+ if r["correct"]:
84
+ new = random.choice([c for c in "ABCDEFGHIJ"[:5] if c != gold.upper()]) if r["kind"] == "mc" else str(int(float(gold.replace(',', ''))) + 7) if re.fullmatch(r"-?[\d,]+(\.0+)?", gold) else None
85
+ else:
86
+ new = gold
87
+ if new is None: continue
88
+ v = r["judge_input"][:m.start(2)] + new + r["judge_input"][m.end(2):]
89
+ orig.append(r["judge_input"]); swapped.append(v); ys.append(r["correct"])
90
+ po, ps = score(orig), score(swapped); ys = np.array(ys)
91
+ res["swap"] = dict(n=len(ys), n_right_made_wrong=int(ys.sum()), n_wrong_made_right=int((~ys).sum()),
92
+ right_orig=float(po[ys].mean()), right_after_swap_to_wrong=float(ps[ys].mean()),
93
+ wrong_orig=float(po[~ys].mean()), wrong_after_swap_to_gold=float(ps[~ys].mean()),
94
+ auroc_on_swapped=roc_auc_score(~ys, ps)) # label of swapped = flipped correctness
95
+ print(json.dumps(res, indent=1))
diagnostics_ladder.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ladder (glm -> qwen -> nemotron-ultra) on the 491 held-out tasks, gated by Jedi vs by the surface-feature baseline."""
2
+ import json, math, random, re, sys
3
+ import numpy as np, torch
4
+ import common
5
+ from datasets import load_from_disk
6
+ from transformers import AutoModelForCausalLM, AutoTokenizer
7
+
8
+ def roc_auc_score(y, p):
9
+ y, p = np.asarray(y, bool), np.asarray(p, float)
10
+ r = np.argsort(np.argsort(p, kind="mergesort")) + 1
11
+ return float((r[y].sum() - y.sum() * (y.sum() + 1) / 2) / (y.sum() * (~y).sum()))
12
+
13
+ class LogisticRegression:
14
+ def fit(self, X, y):
15
+ X = torch.tensor(X, dtype=torch.float32); y = torch.tensor(np.asarray(y, float), dtype=torch.float32)
16
+ self.mu, self.sd = X.mean(0), X.std(0) + 1e-6
17
+ X = (X - self.mu) / self.sd
18
+ self.w = torch.zeros(X.shape[1], requires_grad=True); self.b = torch.zeros(1, requires_grad=True)
19
+ opt = torch.optim.LBFGS([self.w, self.b], max_iter=500)
20
+ def closure():
21
+ opt.zero_grad(); l = torch.nn.functional.binary_cross_entropy_with_logits(X @ self.w + self.b, y); l.backward(); return l
22
+ opt.step(closure); return self
23
+ def predict_proba(self, X):
24
+ X = (torch.tensor(X, dtype=torch.float32) - self.mu) / self.sd
25
+ p = torch.sigmoid(X @ self.w + self.b).detach().numpy(); return np.stack([1 - p, p], 1)
26
+ random.seed(0)
27
+ P = sys.argv[1] if len(sys.argv) > 1 else "jedi-0.5b"
28
+ cal = json.load(open(P + "/calibration.json")); tok = AutoTokenizer.from_pretrained(P); tok.padding_side = "left"
29
+ model = AutoModelForCausalLM.from_pretrained(P, dtype=torch.bfloat16).cuda().eval()
30
+ ds = load_from_disk("data/jedi_judge")
31
+
32
+ def score(views, bs=16):
33
+ out = np.zeros(len(views)); order = np.argsort([len(v) for v in views])
34
+ for i in range(0, len(views), bs):
35
+ idx = order[i:i + bs]
36
+ msgs = [[{"role": "system", "content": common.JEDI_SYSTEM}, {"role": "user", "content": views[j]}] for j in idx]
37
+ enc = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt", padding=True, return_dict=True).to("cuda")
38
+ with torch.no_grad(): l = model(**enc).logits[:, -1].float()
39
+ g = (l[:, cal["accept_token"]] - l[:, cal["escalate_token"]]).cpu().numpy()
40
+ out[idx] = 1 / (1 + np.exp(-(cal["a"] * g + cal["b"])))
41
+ return out
42
+
43
+ def feats(d):
44
+ ag = sorted(set(ds["train"]["agent"])); src = sorted(set(ds["train"]["source"]) | set(ds["ood"]["source"]))
45
+ X = []
46
+ for r in d:
47
+ X.append([math.log1p(r["completion_tokens"]), r["finish"] == "length", not r["pred"].strip()]
48
+ + [r["agent"] == a for a in ag] + [r["source"] == s for s in src])
49
+ return np.array(X, float)
50
+
51
+ base = ds["train"].filter(lambda r: r["sample"] == 0) # one sample per (task, agent): no oversampling dupes in fit
52
+ lr = LogisticRegression().fit(feats(base), base["correct"])
53
+
54
+ from datasets import load_dataset
55
+ from common import LADDER, POOL, STRONGEST
56
+ CHEAP, MID = LADDER[0], LADDER[1]
57
+ d = ds["test"]
58
+ pj = score(d["judge_input"]); ps = lr.predict_proba(feats(d))[:, 1]
59
+ R = {}
60
+ for r, a, b in zip(d.select_columns(["task_id", "agent", "sample", "correct", "completion_tokens"]), pj, ps):
61
+ R[r["task_id"], r["agent"], r["sample"]] = dict(r, pj=float(a), ps=float(b))
62
+ shak = {r["id"]: r for sp in ("train", "test") for r in load_dataset("podstack/Shaktimaan-routing", split=sp)}
63
+ srcs = {"gsm8k", "math", "mmlupro", "arc", "mbpp", "humaneval"}
64
+ tasks = sorted({t for (t, _, _) in R if t.split("-")[0] in srcs and (t, CHEAP, 0) in R and (t, MID, 0) in R and t in shak})
65
+ def ultra(t):
66
+ x = json.loads(shak[t]["agent_results"])[STRONGEST]; return x["correct"], x["completion_tokens"]
67
+ ucost = sum(POOL[STRONGEST]["cost"] * ultra(t)[1] for t in tasks)
68
+ def ladder(key, tau):
69
+ c = cost = 0
70
+ for t in tasks:
71
+ for i, ag in enumerate(LADDER):
72
+ if i == 2:
73
+ ok, tk = ultra(t); cost += POOL[ag]["cost"] * tk; c += ok; break
74
+ a = R[t, ag, 0]; cost += POOL[ag]["cost"] * a["completion_tokens"]
75
+ if a[key] >= tau: c += a["correct"]; break
76
+ return round(c / len(tasks), 4), round(cost / ucost, 4)
77
+ print("n", len(tasks))
78
+ for tau in (0.05, 0.1, 0.2, 0.3, 0.4, 0.45, 0.5):
79
+ print(tau, "jedi", ladder("pj", tau), "shallow", ladder("ps", tau))