| """Rewrite a naturally-typed question into the shape v74 was trained on.
|
|
|
| v74 scores **0.894** on the n=500 benchmark and produced nonsense for every
|
| naturally-phrased question typed into the chat interface. Both are true, and
|
| the reason is that the benchmark generates prompts in the corpus's own format
|
| while a person does not:
|
|
|
| "What is 47 x 6?" -> 40 x 6 = 240, 7 x 6 = 42, total 282 correct
|
| "what is 47 times 6" -> 400 x 6 = 200, 7 x 6 = 42, total 242 wrong
|
|
|
| Probing which features actually matter, rather than assuming:
|
|
|
| | feature | matters |
|
| |----------------------------------|---------|
|
| | operator token (`x` vs `times`) | **yes** |
|
| | a lead-in phrase being present | **yes** |
|
| | capitalisation | no |
|
| | trailing question mark | no |
|
|
|
| `"47 x 6"` with no lead-in was read as algebra ("subtract 6 from both sides"),
|
| so the lead-in is doing real work: it selects the task, not just the register.
|
|
|
| ## What this is and is not
|
|
|
| It is a **presentation** fix. It maps how a person writes an operation onto the
|
| token the model was trained on. It does not compute anything, it never alters a
|
| number, and it never invents operands -- if it cannot recognise the shape it
|
| returns the text untouched so ordinary conversation still reaches the model.
|
|
|
| It does **not** make the model more capable, and it must not be described as
|
| doing so. A question the model gets wrong in the training format stays wrong
|
| here: `"What is 15% of 240?"` returns 26.0 (should be 36) both before and
|
| after normalisation, because `percent` genuinely scores 0.75.
|
|
|
| The rewrite is reported to the caller so the interface can show what was
|
| actually asked. Silently changing someone's question and presenting the answer
|
| as a reply to what they typed would misrepresent the model.
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import re
|
| from dataclasses import dataclass
|
| from typing import List, Optional
|
|
|
|
|
|
|
|
|
| LEAD_IN = {
|
| "multiplication": "What is {a} x {b}?",
|
| "division": "Quick question: {a} / {b}",
|
| "addition": "Please help with this. {a} + {b}",
|
| "subtraction": "Solve this basic math problem: {a} - {b}",
|
| }
|
|
|
| NUMBER = r"-?\d+(?:\.\d+)?"
|
|
|
|
|
| @dataclass(frozen=True)
|
| class Normalised:
|
| """The prompt to send, and an honest record of what was done to it."""
|
|
|
| prompt: str
|
| rule: Optional[str] = None
|
| original: Optional[str] = None
|
|
|
| @property
|
| def changed(self) -> bool:
|
| return self.rule is not None and self.prompt != self.original
|
|
|
|
|
| def _numbers(text: str) -> List[str]:
|
| return re.findall(NUMBER, text)
|
|
|
|
|
| def _clean(text: str) -> str:
|
| return re.sub(r"\s+", " ", text).strip()
|
|
|
|
|
| def _binary(text: str) -> Optional[Normalised]:
|
| """`A <op> B` in any of the ways people write it."""
|
|
|
|
|
|
|
| reversed_subtraction = re.search(
|
| rf"subtract(?:ing)?\s+({NUMBER})\s+from\s+({NUMBER})", text, flags=re.IGNORECASE
|
| )
|
| if reversed_subtraction:
|
| return Normalised(
|
| LEAD_IN["subtraction"].format(
|
| a=reversed_subtraction.group(2), b=reversed_subtraction.group(1)
|
| ),
|
| "subtraction",
|
| )
|
|
|
| operators = [
|
| ("multiplication", r"(?:x|\*|times|multiplied\s+by)"),
|
| ("division", r"(?:/|÷|divided\s+by|over)"),
|
| ("addition", r"(?:\+|plus|added\s+to)"),
|
| ("subtraction", r"(?:-|minus|take\s+away|less)"),
|
| ]
|
| for task, pattern in operators:
|
|
|
|
|
| match = re.search(
|
| rf"({NUMBER})\s*(?:{pattern})\s*({NUMBER})",
|
| text,
|
| flags=re.IGNORECASE,
|
| )
|
| if not match:
|
| continue
|
| a, b = match.group(1), match.group(2)
|
| return Normalised(LEAD_IN[task].format(a=a, b=b), task)
|
| return None
|
|
|
|
|
| def normalise(text: str) -> Normalised:
|
| """Rewrite `text` into the corpus format, or return it unchanged.
|
|
|
| Rules are ordered most-specific first: a two-step question contains a
|
| percent question, and a percent question contains numbers that would
|
| otherwise look like an average.
|
| """
|
|
|
| if not text or not text.strip():
|
| return Normalised(text, None, text)
|
| source = _clean(text)
|
| lowered = source.lower()
|
|
|
|
|
| two_step = re.search(
|
| rf"({NUMBER})\s*(?:%|percent)\s*(?:of)?\s*({NUMBER}).*?"
|
| rf"then\s*(add|subtract|plus|minus)\s*({NUMBER})",
|
| lowered,
|
| )
|
| if two_step:
|
| percent, whole, operation, operand = two_step.groups()
|
| word = "add" if operation in ("add", "plus") else "subtract"
|
| return Normalised(
|
| f"What is {percent}% of {whole}, then {word} {operand}?",
|
| "two_step",
|
| source,
|
| )
|
|
|
| percent = re.search(
|
| rf"({NUMBER})\s*(?:%|percent)\s*(?:of)\s*({NUMBER})", lowered
|
| )
|
| if percent:
|
| return Normalised(
|
| f"What is {percent.group(1)}% of {percent.group(2)}?", "percent", source
|
| )
|
|
|
| if re.search(r"\b(?:average|mean)\b", lowered):
|
| values = _numbers(source)
|
| if len(values) >= 2:
|
| joined = ", ".join(values)
|
| return Normalised(
|
| f"Find the average (mean) of these numbers: {joined}",
|
| "average",
|
| source,
|
| )
|
|
|
| if re.search(r"\b(?:next|sequence|comes\s+after|continue)\b", lowered):
|
| values = _numbers(source)
|
| if len(values) >= 3:
|
| joined = ", ".join(values)
|
| return Normalised(
|
| f"What comes next in the sequence: {joined}?", "sequence", source
|
| )
|
|
|
|
|
|
|
| algebra = re.search(
|
| rf"x\s*([+\-*/])\s*({NUMBER})\s*=\s*({NUMBER})", lowered
|
| )
|
| if algebra:
|
| operator, operand, result = algebra.groups()
|
| return Normalised(
|
| f"Solve for x: x {operator} {operand} = {result}", "algebra_one_step", source
|
| )
|
|
|
| binary = _binary(source)
|
| if binary is not None:
|
| return Normalised(binary.prompt, binary.rule, source)
|
|
|
|
|
|
|
|
|
| return Normalised(source, None, source)
|
|
|