Repository navigation
feat(personaplex): qualify spoken QA with AIPerf #1809
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import json | ||
| import sys | ||
|
|
||
| import pytest | ||
|
|
||
| from trtmc_aiperf_qual.absolute import postprocess | ||
| from trtmc_aiperf_qual.config import Environment | ||
|
|
||
|
|
||
| def test_postprocess_adds_text_without_changing_timing_or_original_artifacts(tmp_path): | ||
| script = tmp_path / "transcribe.py" | ||
| script.write_text("import json,sys\nfrom pathlib import Path\n" | ||
| "d=json.loads(Path(sys.argv[2]).read_text())\n" | ||
| "for row in d.values(): row['text']='Option C'\n" | ||
| "Path(sys.argv[4]).write_text(json.dumps(d))\n") | ||
| observations = {0: {"artifact": "unchanged.npy", "sample_rate": 24000}} | ||
| item = {"postprocess": {"script": script.name}} | ||
| environment = Environment({"repo": str(tmp_path), "serve_python": "/unused-serving-interpreter"}) | ||
| postprocess(environment, item, observations, tmp_path / "run", python=sys.executable) | ||
| assert observations == {0: {"artifact": "unchanged.npy", "sample_rate": 24000, "text": "Option C"}} | ||
| assert json.loads((tmp_path / "run.postprocess-input.json").read_text()) == { | ||
| "0": {"artifact": "unchanged.npy", "sample_rate": 24000}} | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("mutation", ["d.clear()", "d['0']['sample_rate']=16000"]) | ||
| def test_postprocess_rejects_lost_samples_or_modified_evidence(tmp_path, mutation): | ||
| script = tmp_path / "bad.py" | ||
| script.write_text("import json,sys\nfrom pathlib import Path\n" | ||
| "d=json.loads(Path(sys.argv[2]).read_text())\n" + mutation + "\n" | ||
| "Path(sys.argv[4]).write_text(json.dumps(d))\n") | ||
| environment = Environment({"repo": str(tmp_path), "serve_python": sys.executable}) | ||
| with pytest.raises(ValueError, match="postprocessing changed"): | ||
| postprocess(environment, {"postprocess": {"script": script.name}}, | ||
| {0: {"sample_rate": 24000}}, tmp_path / "run") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| """Spoken answer scoring must not reward silence or failed extraction.""" | ||
|
|
||
| import pytest | ||
|
|
||
| from trtmc_aiperf_qual.gold_metrics import spoken_choice | ||
|
|
||
| GOLD = {"answer": "C", "prompt": "Predators eat\nA. lions\nB. humans\nC. bunnies\nD. grass\nSelect A, B, C, or D."} | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("text", ["Option C, bunnies.", "C.", "The answer is C.", "They eat bunnies."]) | ||
| def test_recognizes_the_correct_spoken_answer(text): | ||
| assert spoken_choice(GOLD, {"text": text}) == (True, "C") | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("text", ["", "Thank you for listening.", "A predator needs food.", | ||
| "Option A or option C.", "lions and bunnies"]) | ||
| def test_missing_or_ambiguous_answers_are_never_guessed(text): | ||
| assert spoken_choice(GOLD, {"text": text}) == (False, "") | ||
|
|
||
|
|
||
| def test_wrong_answers_remain_wrong(): | ||
| assert spoken_choice(GOLD, {"text": "Option D, grass."}) == (False, "D") | ||
|
|
||
|
|
||
| def test_an_explicit_answer_overrides_a_word_in_the_explanation(): | ||
| assert spoken_choice(GOLD, {"text": "Bunnies are animals. The answer is A. Okay."}) == (False, "A") | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("text", ["I would say C, definitely.", "You would pick C. That's the answer.", | ||
| "You would pick C. It could also be a human.", "Yes. The answer is C. Okay.", | ||
| "C is the correct answer."]) | ||
| def test_answer_cues_work_inside_a_spoken_reply(text): | ||
| assert spoken_choice(GOLD, {"text": text}) == (True, "C") | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("text", ["The answer is B. C is the correct answer.", | ||
| "They don't eat bunnies.", "Not option C."]) | ||
| def test_conflicting_answers_and_negated_mentions_are_not_correct(text): | ||
| assert spoken_choice(GOLD, {"text": text}) == (False, "") | ||
|
|
||
|
|
||
| def test_question_and_answer_labels_are_not_required_by_transcription(): | ||
| # The scorer, rather than the ASR stage, owns access to gold labels. | ||
| assert spoken_choice(GOLD, {"text": "bunnies", "audio_artifact": "output.wav"}) == (True, "C") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| """A suite resamples dataset audio once, so the native and TRTMC sides read the same samples.""" | ||
|
|
||
| import base64 | ||
| import io | ||
|
|
||
| import numpy as np | ||
| import pytest | ||
|
|
||
| from trtmc_aiperf_qual.config import ConfigError | ||
| from trtmc_aiperf_qual.suites import _hf_rows, _wav_media | ||
|
|
||
| soundfile = pytest.importorskip("soundfile") | ||
| pytest.importorskip("scipy") | ||
|
|
||
|
|
||
| def _encoded(audio, rate): | ||
| buffer = io.BytesIO() | ||
| soundfile.write(buffer, audio, rate, format="WAV", subtype="PCM_16") | ||
| return buffer.getvalue() | ||
|
|
||
|
|
||
| def _decoded(media): | ||
| assert media["$file"]["suffix"] == ".wav" | ||
| return soundfile.read(io.BytesIO(base64.b64decode(media["$file"]["b64"])), dtype="float32") | ||
|
|
||
|
|
||
| def test_audio_is_resampled_once_to_the_suite_rate(): | ||
| tone = 0.5 * np.sin(2 * np.pi * 440 * np.arange(16_000) / 16_000) | ||
| audio, rate = _decoded(_wav_media(_encoded(tone, 16_000), 24_000)) | ||
| assert rate == 24_000 | ||
| assert audio.shape == (24_000,) | ||
| expected = 0.5 * np.sin(2 * np.pi * 440 * np.arange(24_000) / 24_000) | ||
| np.testing.assert_allclose(audio[200:-200], expected[200:-200], atol=2e-3) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("sample_rate", [None, 16_000]) | ||
| def test_audio_keeps_its_samples_without_a_different_suite_rate(sample_rate): | ||
| source = (np.arange(-800, 800) * 16).astype(np.float32) / 32768 # exact PCM16 values | ||
| audio, rate = _decoded(_wav_media(_encoded(source, 16_000), sample_rate)) | ||
| assert rate == 16_000 | ||
| np.testing.assert_array_equal(audio, source) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("sample_rate", [0, -24_000, 24_000.0, "24000", True]) | ||
| def test_invalid_suite_rates_are_rejected_before_the_dataset_loads(sample_rate): | ||
| with pytest.raises(ConfigError, match="audio_sample_rate"): | ||
| _hf_rows("dataset", object(), "revision", {"audio_sample_rate": sample_rate}, {}) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -41,6 +41,39 @@ def choice(gold: Any, observation: Mapping[str, Any] | None, task: str | None = | |||||||||||||||||||||||||||||||||||||||||||
| return bool(answer) and answer == str(gold).strip().upper(), answer | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| def spoken_choice(gold: Any, observation: Mapping[str, Any] | None, | ||||||||||||||||||||||||||||||||||||||||||||
| task: str | None = None) -> tuple[bool, str]: | ||||||||||||||||||||||||||||||||||||||||||||
| """Score a transcribed MCQ reply by an explicit letter or unique complete option text. | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Missing, ambiguous and unparsable answers are wrong. Never guess a letter. | ||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||
| text = _text(observation).strip().lower() | ||||||||||||||||||||||||||||||||||||||||||||
| def affirmative(start: int) -> bool: | ||||||||||||||||||||||||||||||||||||||||||||
| clause = re.split(r"[.,;:!?]", text[:start])[-1] | ||||||||||||||||||||||||||||||||||||||||||||
| return not re.search(r"\b(?:not|never|don't|doesn't|isn't|aren't|can't|cannot|won't)\b", clause) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| patterns = ( | ||||||||||||||||||||||||||||||||||||||||||||
| r"\b(?:option|choice)\s+([a-d])\b", | ||||||||||||||||||||||||||||||||||||||||||||
| r"\b(?:the\s+)?(?:correct\s+)?answer\s+(?:is|would be)\s+([a-d])(?=\s*(?:[.,:;!?]|$))", | ||||||||||||||||||||||||||||||||||||||||||||
| r"\b([a-d])\s+is\s+(?:the\s+)?(?:correct\s+)?answer\b", | ||||||||||||||||||||||||||||||||||||||||||||
| r"\b(?:i|you|we)(?:'d| would)?\s+(?:say|choose|pick)\s+([a-d])(?=\s*(?:[.,:;!?]|$))", | ||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||
| letters = {match.group(1) for pattern in patterns for match in re.finditer(pattern, text) | ||||||||||||||||||||||||||||||||||||||||||||
| if affirmative(match.start())} | ||||||||||||||||||||||||||||||||||||||||||||
| direct = re.fullmatch(r"(?:the (?:correct )?answer is\s+)?([a-d])[.!]?", text) | ||||||||||||||||||||||||||||||||||||||||||||
| if direct: | ||||||||||||||||||||||||||||||||||||||||||||
| letters.add(direct.group(1)) | ||||||||||||||||||||||||||||||||||||||||||||
| if not letters: | ||||||||||||||||||||||||||||||||||||||||||||
| for letter, option in re.findall(r"(?m)^([A-D])\.\s*(.+)$", gold["prompt"]): | ||||||||||||||||||||||||||||||||||||||||||||
| option = " ".join(re.findall(r"\w+", option.lower())) | ||||||||||||||||||||||||||||||||||||||||||||
| raw = re.escape(option).replace(r"\ ", r"\W+") | ||||||||||||||||||||||||||||||||||||||||||||
| positive = any(affirmative(match.start()) for match in re.finditer(r"(?<!\w)" + raw + r"(?!\w)", text)) | ||||||||||||||||||||||||||||||||||||||||||||
| if len(option) >= 3 and positive: | ||||||||||||||||||||||||||||||||||||||||||||
| letters.add(letter.lower()) | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+66
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Do not let a shorter option count as a second answer when it is contained in a longer matched option. The fallback checks each option text on its own. Some OpenBookQA options are word-level prefixes or sub-spans of other options, for example "water" and "salt water". If the reply is "salt water", both options match. Record the matched span for each option. Then drop a candidate whose span lies strictly inside another candidate's span. Both sides are affected equally, so the paired comparison stays fair. The absolute Acc values still go down. ♻️ Proposed fix--- "a/apps/aiperf_qual/trtmc_aiperf_qual/gold_metrics.py"
+++ "b/apps/aiperf_qual/trtmc_aiperf_qual/gold_metrics.py"
@@ -63,13 +63,20 @@
direct = re.fullmatch(r"(?:the (?:correct )?answer is\s+)?([a-d])[.!]?", text)
if direct:
letters.add(direct.group(1))
if not letters:
+ spans: dict[str, list[tuple[int, int]]] = {}
for letter, option in re.findall(r"(?m)^([A-D])\.\s*(.+)$", gold["prompt"]):
option = " ".join(re.findall(r"\w+", option.lower()))
raw = re.escape(option).replace(r"\ ", r"\W+")
- positive = any(affirmative(match.start()) for match in re.finditer(r"(?<!\w)" + raw + r"(?!\w)", text))
- if len(option) >= 3 and positive:
- letters.add(letter.lower())
+ found = [match.span() for match in re.finditer(r"(?<!\w)" + raw + r"(?!\w)", text)
+ if affirmative(match.start())]
+ if len(option) >= 3 and found:
+ spans[letter.lower()] = found
+ for letter, found in spans.items():
+ nested = all(any(o != letter and s <= a and b <= e and (s, e) != (a, b)
+ for o, other in spans.items() for s, e in other) for a, b in found)
+ if not nested:
+ letters.add(letter)
answer = next(iter(letters)).upper() if len(letters) == 1 else ""
return bool(answer) and answer == str(gold["answer"]).strip().upper(), answer
📝 Committable suggestion
Suggested change
🧰 Tools🪛 ast-grep (0.45.3)[warning] 70-70: Regex pattern passed to re is built from a non-literal (variable, call, concatenation, or f-string) value. If that value is attacker-controlled it can introduce a malicious pattern with catastrophic backtracking (ReDoS). Use a hardcoded literal pattern, or validate/escape untrusted input with re.escape() and bound the regex complexity before compiling. (redos-non-literal-regex-python) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
| answer = next(iter(letters)).upper() if len(letters) == 1 else "" | ||||||||||||||||||||||||||||||||||||||||||||
| return bool(answer) and answer == str(gold["answer"]).strip().upper(), answer | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| def _squash(text: str) -> str: | ||||||||||||||||||||||||||||||||||||||||||||
| return re.sub(r"\s+", " ", text.strip().lower()) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -206,7 +239,7 @@ def box_iou50(gold: Any, observation: Mapping[str, Any] | None, task: str | None | |||||||||||||||||||||||||||||||||||||||||||
| return _iou(predicted, target) >= 0.5, match.group(0) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| BINARY: dict[str, Callable[..., tuple[bool, str]]] = {"choice": choice, "contains": contains, "code_pass": code_pass, | ||||||||||||||||||||||||||||||||||||||||||||
| BINARY: dict[str, Callable[..., tuple[bool, str]]] = {"choice": choice, "spoken_choice": spoken_choice, "contains": contains, "code_pass": code_pass, | ||||||||||||||||||||||||||||||||||||||||||||
| "top1": top1, "box_iou50": box_iou50} | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: NVIDIA/TensorRT-Model-Connect
Length of output: 8552
🏁 Script executed:
Repository: NVIDIA/TensorRT-Model-Connect
Length of output: 31360
🏁 Script executed:
Repository: NVIDIA/TensorRT-Model-Connect
Length of output: 14207
Move GPU postprocessing out of the serving context.
_suite_sidecallspostprocessbefore returning. Bothrun_nativeandrun_candidatecall it while theirserving_replicascontexts remain active. Inoverlapped_acc, both sides share the MPS daemon until thefinished/releasehandshake completes.transcribe_outputs.pyloads Whisper on CUDA and runs CUDA inference. That work can overlap the other side's profiling and perturb its Task-call P50.A postprocessing failure also propagates through
run_native's per-precisionexceptblock. Whenrun_nativeuses its normal precision list, the native run can retry at the next precision instead of reporting the failure as a scoring error.Return the observations from each side, then run the configured postprocessors after both serving contexts and the shared MPS daemon have been released. Preserve the returned observations and apply the postprocessor updates before scoring.
🤖 Prompt for AI Agents