Spaces:
Sleeping
Sleeping
Download pg_reading.py from ggautreau/PanGramGraph: direct link, hf CLI and curl.
- Browser
- Download file 168 kB
-
https://huggingface.co/spaces/ggautreau/PanGramGraph/resolve/main/pg_reading.py
- Command line
-
hf download hf://spaces/ggautreau/PanGramGraph/pg_reading.py
-
curl -L -o pg_reading.py https://huggingface.co/spaces/ggautreau/PanGramGraph/resolve/main/pg_reading.py
168 kB
| """The reading of a window written by an LLM, for the Findings tab of the PanGramGraph page (serve_live.py /reading). | |
| The page writes a rule-based reading of the window shown (autoReading in page_template.html). Here an LLM writes it | |
| in prose, by a skill document (reading_skill.md: what the numbers mean, a procedure, the hand-written reading of the | |
| dnaA window as its reference), in the same five sections for every window, and every sentence names the facts lines it | |
| is written from: each one is checked against those lines before the page shows it, and dropped if it does not hold. | |
| the browser sends only the window's id: dnaA, or the anchor family of a region window (pgb_region.build; a backbone | |
| family). The server builds the facts itself from its own data (facts(D)): the columns, their stretches (spine, | |
| fork, variable, the same rules as the page's autoReading), the stretch most and least in regions of plasticity, | |
| the genes and their products, the model's accuracy and hesitation, all as display-ready numbers: 2,000-2,900 | |
| tokens, after a system message of ~2,400 (the skill, ~1,800, included). No text of the client reaches the LLM. | |
| skill: reading_skill.md beside this file (READING_SKILL for another path), read once and put in the system message: | |
| what the pangenome words mean (persistent, shell, cloud, regions of plasticity, spots, spine, fork, branch), what | |
| the model's three numbers mean and do not mean (named, bits, perplexity), what each section says, the procedure to | |
| follow in order (the gene groups and the one or two interpretations included), and two reference readings in the | |
| output form: the hand-written one of the dnaA window and one of a window nearly all spine. Its sha is part of the | |
| cache key, so a changed skill makes new readings; without the file the prompt keeps the hand-written reading alone. | |
| form: the same five paragraphs in the same order for every window, each opening with its tag and a short bold | |
| sentence: @start @variable @model @end @whole (SECTIONS), 150 to 200 words asked (WORDS_TARGET), at most 220 | |
| served (WORDS_MAX), temperature 0. | |
| citations: every facts line carries its id ([W], [G], [C], [M], [S3], [S3.c12], [S3.b40], [Q]) and every sentence | |
| ends with the ids of the lines it is written from, in braces. The check is per sentence, against those lines only | |
| (stricter than a check over the whole facts): every number must be one they display, in the right role (a share, a | |
| share in a region of plasticity and a share named are not exchangeable), and bound to what it is said of: a gene's | |
| share to that gene, a column's to that column, a range to the lowest and highest of the columns named (or a range a | |
| line shows), "from A to B" to the first and last column, "most hesitant" / "fewest named" to the column and value | |
| the cited line states, [G]'s and [C]'s numbers to their own places. Every gene name, identifier and column must stand | |
| in them and sit where the sentence puts it; the kind of a column (spine, fork, variable) and the partition of a family | |
| as the facts give them; a branch spoken of with its own families and share. Every word inside [[ ]] must come from | |
| the products and gene names of the lines cited and the label must fit most of the families at the columns it names | |
| (in one operon, two of them); outside [[ ]] no product word. [M] and [C] in their direction; no "readily", no | |
| unstated trend ("fewer and fewer"), no "then" pointing back, no "lack", no unstated superlative. A sentence whose | |
| facts are not in its citations is dropped, a sentence repeating an earlier one is left out, and the model is asked | |
| once again for the whole reading with the offending sentences named (two dropped, a paragraph without its opening | |
| sentence, or no interpretation where [Q] offers one); if too little is left (a missing @start or @whole, two missing | |
| sections, under 110 words) the page keeps its rule-based reading (status "fallback"). | |
| [Q], the groups: the server finds where an interpretation may stand (genes sharing a prefix, a word shared along | |
| a run of products, a branch whose families agree) and the words their products share; the model words the label. | |
| tidy(text) puts the model's slips of form right without a new call: "92-99%" -> "92 to 99%", "RGP 5%" -> "5% in a | |
| region of plasticity", "share 90%" -> "in 90% of chromosomes", a row of more than 6 genes -> `first` to `last`. | |
| length: over 220 words, whole sentences are dropped from the least telling sections, then a whole section; never a | |
| part of a sentence. The check verifies the numbers, the names and what the words inside [[ ]] rest on, not the prose. | |
| cache: per window, in memory and in <READING_CACHE>/<window>.json, also read from pgb/readings/ (the readings the | |
| Space carries), keyed by a hash of the facts, the prompt and the model: new data or a new prompt make a new | |
| reading. Limits on new generations: per client (X-Forwarded-For only behind a trusted proxy), per client per day, | |
| per day for the whole server (count and dollars), one generation per window at a time, two at once (others wait | |
| their turn in a short queue), only for requests from the page's own site (Sec-Fetch-Site / Origin). | |
| Model: deepseek-ai/DeepSeek-V4.1-Flash through Hugging Face Inference Providers (router.huggingface.co, OpenAI | |
| compatible), no reasoning (chat_template_kwargs thinking=false), at most 800 tokens out (median 410 per attempt over | |
| the 112 readings of prompt r9, 460 at most). Token: HF_TOKEN (the | |
| Space's secret), else the token of `huggingface-cli login`; used only in the request's header (never followed | |
| through a redirect), never logged, never written, never sent to the page; the router's error text is not passed on. | |
| READING=off disables new readings (cached ones are still served; /health says so) | |
| READING_MODEL, READING_PROVIDERS=deepinfra,novita the model, and the providers tried in that order | |
| READING_SKILL=reading_skill.md the skill document; READING_TEMPERATURE=0; READING_WORDS=220 the reading's length | |
| READING_PER_IP=6 READING_PER_IP_S=600 READING_PER_IP_DAY=30 new readings per client per 600 s, and per day | |
| READING_DAILY_CAP=150 new readings per UTC day, the whole server; READING_DAILY_USD=0.30 and dollars per day | |
| (every call counted, a call without usage or cut by a timeout at its worst case) | |
| READING_TIMEOUT=22 seconds per LLM call (then the next provider); READING_QUEUE=6 generations waiting | |
| READING_CACHE=pgb/readings where new readings and the day's counter are written (e.g. /data/readings on a Space | |
| with persistent storage); pgb/readings/ is read too | |
| TRUST_PROXY=1 read the client from X-Forwarded-For (always on a Space: SPACE_ID) | |
| python3 pg_reading.py facts dnaA | <window.json> the facts of a window (a region: pgb_region.build output) | |
| python3 pg_reading.py check dnaA | <window.json> <text.md> the checker on a text with its tags and citations, | |
| sentence by sentence, against that window's facts, and the reading it leaves | |
| python3 pg_reading.py skill the skill document read, its sha and its size | |
| python3 pg_reading.py pregen [--chain] [--dry] [dnaA] [anchor ...] write readings ahead (--chain: the windows of | |
| the page's Next and Previous buttons from the dnaA window on), into READING_CACHE, without the server | |
| """ | |
| import os, re, json, time, html, hashlib, threading, collections, ipaddress, urllib.request, urllib.error, urllib.parse, datetime | |
| from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN, InvalidOperation | |
| def _env(k, d, f=float): | |
| try: return f(os.environ.get(k, d)) | |
| except ValueError: return f(d) | |
| MODEL = os.environ.get("READING_MODEL", "deepseek-ai/DeepSeek-V4.1-Flash") | |
| PROVIDERS = [p.strip() for p in os.environ.get("READING_PROVIDERS", "deepinfra,novita").split(",") if p.strip()] | |
| PRICES = {"deepinfra": (0.2, 0.6), "novita": (0.3, 1.2), "baseten": (0.3, 1.2)} # $ per million tokens in, out (router, 2026-09) | |
| URL = "https://router.huggingface.co/v1/chat/completions" | |
| MAX_TOKENS = 800 # median 540 out; 700 cut 2 of 130 texts | |
| TEMPERATURE = _env("READING_TEMPERATURE", "0") # 0: the same facts and the same skill give the same reading | |
| TIMEOUT = _env("READING_TIMEOUT", "22") | |
| BUDGET = 70.0 # seconds for a whole generation (two attempts, a provider fallback) | |
| PER_IP = _env("READING_PER_IP", "6", int) | |
| PER_IP_S = _env("READING_PER_IP_S", "600") | |
| PER_IP_DAY = _env("READING_PER_IP_DAY", "30", int) | |
| DAILY_CAP = _env("READING_DAILY_CAP", "150", int) | |
| DAILY_USD = _env("READING_DAILY_USD", "0.30") | |
| BUNDLED = "pgb/readings" # the readings shipped with the code (the Space serves them without a call) | |
| CACHE_DIR = os.environ.get("READING_CACHE") or BUNDLED | |
| FAIL_TTL = 6 * 3600 # a window whose text failed the checker twice is tried again after this long | |
| AUTH_RETRY = 600 # a token refused by the router: tried again after this long | |
| MAX_PARALLEL = 2 # LLM generations at once | |
| QUEUE_MAX = _env("READING_QUEUE", "6", int) # new generations waiting for one of those slots | |
| SLOT_WAIT = 30.0 # seconds a new generation waits for a slot | |
| WAITERS = 4 # requests waiting for the generation of the same window | |
| PROMPT_VERSION = "r9" # r9: the skill v2, [M] by kind of column, [Q] the groups, numbers bound to their genes and columns | |
| WORDS_MAX = _env("READING_WORDS", "220", int) # the length served: whole sentences are dropped to fit under it | |
| WORDS_TARGET = _env("READING_TARGET", "200", int) # the length asked of the model: below the limit, so nothing has to be cut | |
| WORDS_MIN_ASK = 150 # the shortest reading asked for | |
| WORDS_MIN = 110 # what must be left after the drops for the reading to be served | |
| SECTIONS = ("start", "variable", "model", "end", "whole") # the sections, in this order, of every reading | |
| KEEP = ("start", "whole") # the sections a reading cannot lose | |
| DROP_ORDER = ("variable", "model", "start", "end", "whole") # whose last sentence goes first when the text is too long | |
| CITES_MAX = 8 # facts lines one sentence may cite (a stretch and the columns it walks) | |
| SKILL_PATH = os.environ.get("READING_SKILL") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "reading_skill.md") | |
| DNAA = "dnaA" # the id of the page's home window (pgb/graph_pgb.json, all 2,002 genomes) | |
| SPACE = bool(os.environ.get("SPACE_ID")) | |
| TRUST_PROXY = SPACE or os.environ.get("TRUST_PROXY") == "1" | |
| class ReadingError(Exception): | |
| """an answer other than a reading: HTTP status, a machine-readable code (the page words it), retry_s""" | |
| status, code = 500, "error" | |
| def __init__(self, msg, retry_s=None, code=None): | |
| super().__init__(msg); self.retry_s = retry_s; self.code = code or type(self).code | |
| def body(self): | |
| b = dict(error=str(self), code=self.code) | |
| if self.retry_s: b["retry_s"] = int(self.retry_s) | |
| if isinstance(self, Unavailable): b["available"] = False | |
| return b | |
| class Unavailable(ReadingError): status, code = 503, "unavailable" # off, no token, token refused, no credit, the day's cap | |
| class Limited(ReadingError): status, code = 429, "limit" # this client's new readings | |
| class Busy(ReadingError): status, code = 503, "busy" # other readings are being written: retry_s | |
| class Upstream(ReadingError): status, code = 502, "upstream" # the LLM could not be reached | |
| class Refused(ReadingError): status, code = 403, "cross_site" # a new reading asked for by another site's page | |
| # ---------------------------------------------------------------- numbers as the facts display them | |
| def D_(x): return Decimal(str(x)) | |
| def fmt_pct(v): | |
| """a share (0-1) as a percent: whole numbers, one decimal when that would show 0% or 100% for a share that is not""" | |
| x = D_(v) * 100 | |
| q = x.quantize(Decimal("1"), ROUND_HALF_UP) | |
| if (q == 100 and x < 100) or (q == 0 and x > 0): q = x.quantize(Decimal("0.1"), ROUND_HALF_UP) | |
| if q == 100 and x < 100: q = Decimal("99.9") | |
| if q == 0 and x > 0: q = Decimal("0.1") | |
| return f"{q}%" | |
| def fmt_bits(v): return f"{D_(v).quantize(Decimal('0.01'), ROUND_HALF_UP)}" | |
| def fmt_fam(v): return f"{D_(v).quantize(Decimal('0.1'), ROUND_HALF_UP)}" | |
| def fmt_int(v): return f"{int(v):,}" | |
| class Facts: | |
| """the facts text of a window, every number it displays by kind (pct, bits, fam, int), and for the checker: each | |
| line's id and columns and stretch, the columns of each gene, the columns stated most hesitant and fewest named, the | |
| stretches most and least in regions of plasticity, the inside/outside comparison. Each line is shown with its id | |
| ([W], [S3], [S3.c12]): a sentence of the reading cites the ids of the lines it is written from.""" | |
| def __init__(self): | |
| self.ids = [] | |
| self.lines = []; self.info = []; self.nums = collections.defaultdict(set); self.genes = set(); self.frags = set() | |
| self.at = collections.defaultdict(set); self.atp = collections.defaultdict(set); self.hes_cols = set(); self.low_cols = set(); self.plastic = {} | |
| self.comp = None; self.unit = "genomes"; self.label = "" | |
| self.pend = []; self.pshow = set(); self.pwords = set(); self.pnames = set(); self.kind = None; self.hes_spine = None | |
| self.gshare = collections.defaultdict(set); self.pshare = collections.defaultdict(set); self.gtop = None; self.gbits = None | |
| def reg(self, s, kind): | |
| v = Decimal(s.rstrip("%").replace(",", "")); self.nums[kind].add(v); return s | |
| def pct(self, v): return self.reg(fmt_pct(v), "pct") | |
| def bits(self, v): return self.reg(fmt_bits(v), "bits") | |
| def fam(self, v): return self.reg(fmt_fam(v), "fam") | |
| def int(self, v): return self.reg(fmt_int(v), "int") | |
| def fmly(self, n, k=None, alt=False): | |
| """a family of the line being built: its gene name and the words of its whole product, truncated or not shown | |
| or not — the scope of an interpretation is judged over the families of a line, each with what it really is | |
| (alt: a second family at that column, not what the stretch is made of)""" | |
| self.pend.append(dict(label=n.get("label") or "", col=k, words=pwords(n), alt=alt, part=PART.get(n.get("partition")))) | |
| self.pwords |= pwords(n, gene=False) | |
| return n | |
| def shown(self, s): | |
| """a product string the facts show: its words in full, so a product cut short ("…methy…") still carries them""" | |
| self.pshow |= {w for w in re.findall(r"[A-Za-z][A-Za-z'-]{2,}", s or "")} | |
| return s | |
| def name(self, n, short=40, k=None, alt=False): | |
| """a family as the facts name it: its gene name, else its product in quotes (k: its column, for the checker)""" | |
| self.fmly(n, k, alt) | |
| if n.get("named"): | |
| self.genes.add(n["label"]) | |
| if k is not None: self.at[n["label"]].add(k) | |
| return n["label"] | |
| lab = n.get("product") or n.get("label") or "unnamed family" | |
| if re.fullmatch(r"[A-Z0-9]+_RS\d+", lab): lab = "unnamed family" | |
| self.pnames |= {w.lower() for w in re.findall(r"[A-Za-z][A-Za-z'-]{3,}", lab)} # a family the facts name by its product: | |
| if k is not None: self.atp[lab.lower()].add(k); self.atp[self.text(lab, short).lower()].add(k) | |
| return '"' + self.text(lab, short) + '"' | |
| def text(self, s, short=48): | |
| """a product: shortened; its words followed by a number ("family 4") are names, not numbers, in a text""" | |
| self.shown(s) # its words in full: the cut is only in the display | |
| s = s if len(s) <= short else s[:short - 1].rstrip() + "…" | |
| for m in re.finditer(r"([A-Za-z][\w'-]*)\s+(\d+)\b(?!\.\d)", s): self.frags.add(f"{m.group(1)} {m.group(2)}".lower()) | |
| return s | |
| def add(self, s, cols=(), head=None, glob=False, id=None): | |
| """a line; id: the id the reading cites it by; cols: the columns it is about; head: the index of its stretch's | |
| line; glob: about the whole window""" | |
| self.ids.append(id or f"F{len(self.lines)}") | |
| fams, show = self.pend, self.pshow; self.pend = []; self.pshow = set() | |
| self.lines.append(s); self.info.append(dict(cols=set(cols), head=head, glob=glob, fams=fams, show=show)) | |
| return len(self.lines) - 1 | |
| def txt(self): return "\n".join(f"[{i}] {s}" for i, s in zip(self.ids, self.lines)) | |
| def index(self): return {i: k for k, i in enumerate(self.ids)} | |
| def columns(D): | |
| """the window's columns as the page's autoReading sees them: the most carried family (at its most common | |
| column), its share of the genomes, the share of the second one; spine >= 90 %, fork: a second one >= 10 %""" | |
| W, NG = D["meta"]["window"], D["meta"]["n_genomes"] | |
| pl = {r["locus"]: r for r in D["per_locus"]}; by = collections.defaultdict(list) | |
| for n in D["nodes"]: by[n["locus"]].append(n) | |
| cols = [] | |
| for k in range(W): | |
| here = sorted(by.get(k, []), key=lambda n: -n["n"]) | |
| if not here: continue | |
| top = here[0]; tot = sum(n["n"] for n in here) or 1 | |
| s1 = top["n"] / NG; s2 = here[1]["n"] / NG if len(here) > 1 else 0 | |
| r = pl.get(k) | |
| rgp = r["rgp"] if r and r.get("rgp") is not None else sum(n["n"] * n["rgp"] for n in here) / tot | |
| cols.append(dict(k=k, top=top, here=here, s1=s1, kind="spine" if s1 >= 0.9 else "fork" if s2 >= 0.1 else "variable", | |
| alts=[n for n in here[1:] if n["n"] / NG >= 0.1], rgp=rgp, r=r)) | |
| runs = [] | |
| for c in cols: | |
| if runs and runs[-1]["kind"] == c["kind"] and c["k"] == runs[-1]["b"] + 1: runs[-1]["b"] = c["k"]; runs[-1]["cols"].append(c) | |
| else: runs.append(dict(kind=c["kind"], a=c["k"], b=c["k"], cols=[c])) | |
| return cols, runs | |
| def pwords(n, gene=True): | |
| """the words of a family: its whole product and its gene name, split on hyphens, slashes and apostrophes too | |
| ("pseudouridine-5'-phosphate glycosidase" gives phosphate and glycosidase), lowercase, the generic ones dropped""" | |
| out = {w.lower() for w in re.findall(r"[A-Za-z][A-Za-z]{2,}", n.get("product") or "")} | |
| out |= {w.lower() for w in re.findall(r"\b[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+\b", n.get("product") or "") if re.search(r"[A-Za-z]", w)} | |
| if gene and n.get("named") and n.get("label"): out.add(n["label"].lower()) | |
| return out - GENERIC | |
| PART = {"persistent": "P", "shell": "S", "cloud": "C"} | |
| GENERIC = set("""protein family domain domain-containing containing putative probable transcriptional regulator subunit hypothetical | |
| uncharacterized dna-binding system component type like bifunctional membrane inner outer protein-containing lipoprotein | |
| periplasmic cytoplasmic small large chain""".split()) | |
| def words(p): | |
| """what a product says: its words that are neither generic nor a protein name""" | |
| return {w.lower() for w in re.findall(r"[A-Za-z][\w-]{3,}", p or "") if w.lower() not in GENERIC and not re.fullmatch(r"[A-Z][a-z]{2}[A-Z]\w*", w)} | |
| def informative(p): | |
| """a product that says more than its gene name: a long word, or one with capitals or digits""" | |
| if not p or p.lower() == "hypothetical protein": return False | |
| ws = [w.lower() for w in re.findall(r"[A-Za-z][\w/-]{3,}", p)] | |
| return any((w not in GENERIC and not re.fullmatch(r"[a-z]{3}[a-z0-9]*", w)) or len(w) > 7 for w in ws if w not in GENERIC) | |
| QSTOP = GENERIC | set("""binding atp dna abc duf cassette domain-containing hypothetical family protein subunit class enzyme | |
| terminal substrate complex acid dependent dehydro deoxy phospho beta alpha gamma delta atp-binding substrate-binding | |
| nad-binding fad-binding dna-binding site""".split()) | |
| def groups_line(F, cols, secs, BR): | |
| """[Q]: the gene groups whose products agree, found by the server, where a [[ ]] may stand: a run of columns whose | |
| most carried genes share a prefix (an operon: leu, cai, fli; waa and rfa are one), a run of columns whose products | |
| share a word though their names differ (flagellar: fli, flh, mot), a branch whose families share one. Each with the | |
| words most of its families' products carry (in an operon, a third of them and two at least): the words a label | |
| may use and still fit the families at those columns.""" | |
| sid = {c["k"]: f"S{i}" for i, sec in enumerate(secs, 1) for c in sec["cols"]} | |
| def agree(ns, frac): | |
| cnt = collections.Counter(w for n in ns for w in sorted(pwords(n, gene=False) - QSTOP) if len(w) >= 4) | |
| return [w for w, v in sorted(cnt.items(), key=lambda x: (-x[1], x[0])) if v >= 2 and v >= frac * len(ns)][:4] # ties: a-z | |
| lab = lambda n: n["label"] if n.get("named") else None | |
| out = [] | |
| run = [] # 1. operons: consecutive genes sharing a prefix | |
| for c in cols + [None]: | |
| n = c["top"] if c else None | |
| g = lab(n) if n else None | |
| pre = g[:3].lower() if g and re.fullmatch(r"[a-z]{3}[A-Z]\w*", g) and not g.startswith("y") else None | |
| pre = {"rfa": "waa"}.get(pre, pre) | |
| if run and pre and pre == run[-1][2] and c["k"] == run[-1][0] + 1: run.append((c["k"], n, pre)); continue | |
| if len(run) >= 3: | |
| ws = agree([x[1] for x in run], 1 / 3) | |
| if ws: out.append(dict(a=run[0][0], b=run[-1][0], first=lab(run[0][1]), last=lab(run[-1][1]), words=ws, | |
| ids=sorted({sid[x[0]] for x in run}, key=lambda x: int(x[1:])))) | |
| run = [(c["k"], n, pre)] if pre else [] | |
| taken = set().union(set(), *(set(range(g["a"], g["b"] + 1)) for g in out)) | |
| cnt = collections.Counter(w for c in cols for w in sorted(pwords(c["top"], gene=False) - QSTOP) if len(w) >= 5) | |
| for w, v in sorted(cnt.items(), key=lambda x: (-x[1], x[0]))[:8]: # 2. a word shared along a run of columns (ties: a-z, | |
| # never the set's order, which changes with the process) | |
| if v < 3: break | |
| hits = [c["k"] for c in cols if w in pwords(c["top"], gene=False)] | |
| runs_ = [[hits[0]]] | |
| for k in hits[1:]: | |
| if k - runs_[-1][-1] <= 2: runs_[-1].append(k) | |
| else: runs_.append([k]) | |
| for r in runs_: | |
| a, b = r[0], r[-1] | |
| if len(r) < 3 or len(r) < 0.6 * (b - a + 1) or len(set(range(a, b + 1)) & taken) * 2 > b - a + 1: continue | |
| ns = [c["top"] for c in cols if a <= c["k"] <= b] | |
| ws = [w] + [x for x in agree(ns, 0.5) if x != w][:3] | |
| first, last = next(c["top"] for c in cols if c["k"] == a), next(c["top"] for c in cols if c["k"] == b) | |
| out.append(dict(a=a, b=b, first=lab(first), last=lab(last), words=ws, ids=sorted({sid[k] for k in range(a, b + 1) if k in sid}, | |
| key=lambda x: int(x[1:])))) | |
| taken |= set(range(a, b + 1)) | |
| for bid, bb in BR: # 3. a branch whose families agree | |
| ws = agree([n for _, n in bb], 0.5) | |
| if ws: out.append(dict(a=bb[0][0], b=bb[-1][0], branch=bid, words=ws, ids=[bid])) | |
| out = [g for g in out if not any(h is not g and h["a"] <= g["a"] and g["b"] <= h["b"] and not g.get("branch") | |
| and set(h["words"]) & set(g["words"]) for h in out)] # a group inside a larger one: one | |
| if not out: return | |
| out = sorted(sorted(out, key=lambda g: -(g["b"] - g["a"]))[:5], key=lambda g: g["a"]) | |
| def say(g): | |
| where = f"columns {F.int(g['a'])}-{F.int(g['b'])}" | |
| if g.get("branch"): return f"the branch [{g['branch']}] through {where}: {', '.join(g['words'])}" | |
| who = f" ({g['first']} to {g['last']})" if g.get("first") and g.get("last") else "" | |
| return f"{where}{who} [{', '.join(g['ids'])}]: {', '.join(g['words'])}" | |
| F.add("GROUPS whose products agree, the places for a [[ ]] (name that group's columns exactly, cite its line and [Q], make " | |
| "one phrase from its words): " + "; ".join(say(g) for g in out) + ".", glob=True, id="Q") | |
| def facts(D, wid): | |
| """the facts of one window (graph data: pgb/graph_pgb.json for dnaA, pgb_region.build for the others)""" | |
| F = Facts(); m = D["meta"]; W = m["window"]; NG = m["n_genomes"]; home = wid == DNAA | |
| cols, runs = columns(D); unit = "genomes" if home else "chromosomes"; F.unit = unit | |
| F.kinds = {c["k"]: c["kind"] for c in cols} | |
| for c in cols: # each gene's share at the columns it is most carried at | |
| if c["top"].get("named"): F.gshare[c["top"]["label"]].add(Decimal(fmt_pct(c["s1"]).rstrip("%"))) | |
| if home: | |
| full = m.get("full_windows") | |
| F.add(f"WINDOW: the dnaA window: {F.int(W)} columns from dnaA (column {F.int(0)}) in its direction, in all {F.int(NG)} genomes " | |
| f"of the PanGBank E. coli pangenome; {F.int(m['calls'])} calls of the model, each on a genome's real proteins." | |
| + (f" {F.int(full)} of the {F.int(NG)} genomes have all {F.int(W)} columns on one contig; in the others the window " | |
| f"stops earlier, at a contig end." if full else ""), glob=True, id="W") | |
| else: | |
| a = m["anchor_label"] | |
| if re.fullmatch(r"[a-z]{3}[A-Z]?\w*", a or ""): F.genes.add(a); F.at[a].add(0) | |
| pos = m.get("anchor_pos") | |
| where = f"; about genes {F.int(pos)} to {F.int(pos + W - 1)} from dnaA" if pos is not None else "" | |
| F.add(f"WINDOW: the {a} window: {F.int(W)} columns, column {F.int(0)} is the anchor {a}, then the next {F.int(W - 1)} genes " | |
| f"in dnaA's direction{where}. Read in {F.int(NG)} of the {F.int(m['genomes_total'])} complete chromosomes (those " | |
| f"carrying {a} once); {F.int(m['calls'])} calls of the model, each on a chromosome's real proteins.", glob=True, id="W") | |
| one = unit[:-1] | |
| F.add(f"TERMS: share: {unit} carrying the family in the window. named: calls where the model names the gene at that column " | |
| f"from the genes before it. bits: the model's entropy there, its hesitation (the more bits, the more hesitant). " | |
| f"perplexity (whole window only): how surprised the model is by the real gene. RGP N%: N% of the calls there are on a " | |
| f"gene inside a region of genomic plasticity (panRGP). P/S/C: persistent, shell, cloud (PPanGGOLiN). Spine column: " | |
| f"one family in {F.pct(0.9)} of {unit} or more; fork: a second family in {F.pct(0.1)} or more; variable: neither. A " | |
| f"persistent family is in nearly every {one}: in a fork or variable column its lower share means it lies beyond the " | |
| f"window's end in the other {unit} (genes inserted upstream push it out{', or a contig ends' if home else ''}); never " | |
| f"call it rare or say they lack it.", glob=True, id="T") | |
| pl = D["per_locus"]; n_all = sum(r["n"] for r in pl) or 1 | |
| ent = sum(r["entropy"] * r["n"] for r in pl) / n_all | |
| part, rg = D.get("by_partition") or {}, D.get("by_rgp") or {} | |
| t = lambda r: f"{F.int(r['n'])} calls, named {F.pct(r['top1_dec'])}, {F.bits(r['entropy'])} bits" if r else "no calls" | |
| nk = collections.Counter(c["kind"] for c in cols) | |
| F.gtop = Decimal(fmt_pct(m["top1_dec"]).rstrip("%")); F.gbits = Decimal(fmt_bits(ent)) | |
| F.add(f"WHOLE WINDOW: the model names {F.pct(m['top1_dec'])} of genes, {F.bits(ent)} bits on average (perplexity " | |
| f"{F.fam(m['ppl'])} families). By partition of the gene: persistent {t(part.get('persistent'))}; shell " | |
| f"{t(part.get('shell'))}; cloud {t(part.get('cloud'))}. Outside an RGP {t(rg.get('outside an RGP'))}; inside an " | |
| f"RGP {t(rg.get('inside an RGP'))}. Columns: {F.int(nk['spine'])} spine, {F.int(nk['fork'])} fork, " | |
| f"{F.int(nk['variable'])} variable.", glob=True, id="G") | |
| RI, RO = rg.get("inside an RGP"), rg.get("outside an RGP") | |
| if RI and RO and RI["n"] and RO["n"]: | |
| e_i, e_o, a_i, a_o = RI["entropy"], RO["entropy"], RI["top1_dec"], RO["top1_dec"] | |
| h = "hesitates more" if e_i > 1.2 * e_o + 0.02 else "hesitates less" if e_i < 0.8 * e_o - 0.02 else "hesitates about as much" | |
| n_ = "names fewer genes" if a_i < a_o - 0.03 else "names more genes" if a_i > a_o + 0.03 else "names about as many genes" | |
| F.comp = dict(h=h, n=n_, ei=F.bits(e_i), eo=F.bits(e_o), ai=F.pct(a_i), ao=F.pct(a_o)) | |
| F.add(f"COMPARISON (computed, over calls): inside an RGP the model {h} than outside ({F.comp['ei']} against {F.comp['eo']} bits) " | |
| f"and {n_} ({F.comp['ai']} against {F.comp['ao']}); {F.int(RI['n'])} of the {F.int(RI['n'] + RO['n'])} calls are inside an RGP.", glob=True, id="C") | |
| by_k = {c["k"]: c for c in cols} | |
| def mean(rs, key): | |
| n = sum(r["n"] for r in rs); return sum(r[key] * r["n"] for r in rs) / n if n else None | |
| hot = [r for r in pl if r["locus"] in by_k and by_k[r["locus"]]["rgp"] >= 0.5] | |
| cold = [r for r in pl if r["locus"] in by_k and by_k[r["locus"]]["rgp"] < 0.5] | |
| top = [r for r in sorted(pl, key=lambda r: -r["entropy"])[:3] if r["locus"] in by_k] | |
| if pl: F.low_cols.add(min(pl, key=lambda r: r["top1_dec"])["locus"]) # the window's fewest named: its value is in the facts | |
| F.hes_cols |= {r["locus"] for r in top} | |
| hs = "; ".join(f"column {F.int(r['locus'])} ({F.name(by_k[r['locus']]['top'], k=r['locus'])}, a {by_k[r['locus']]['kind']} column) " | |
| f"{F.bits(r['entropy'])} bits, RGP {F.pct(by_k[r['locus']]['rgp'])}" for r in top) | |
| def where(rs): | |
| """the columns of a short list, as the facts write columns ("columns 9, 36 and 40"; a run as "12-17")""" | |
| ks = sorted(r["locus"] for r in rs); runs_ = [] | |
| for k in ks: | |
| if runs_ and k == runs_[-1][1] + 1: runs_[-1][1] = k | |
| else: runs_.append([k, k]) | |
| if len(runs_) > 6: return "" | |
| it = [F.int(a) if a == b else f"{F.int(a)}-{F.int(b)}" for a, b in runs_] | |
| return " (column " + it[0] + ")" if len(it) == 1 and "-" not in it[0] else \ | |
| " (columns " + (", ".join(it[:-1]) + " and " + it[-1] if len(it) > 1 else it[0]) + ")" | |
| F.add(f"HESITATION: the most hesitant columns of the window: {hs}. " | |
| + (f"The {F.int(len(hot))} column{'' if len(hot) == 1 else 's'}{where(hot)} where most calls are in an RGP: named " | |
| f"{F.pct(mean(hot, 'top1_dec'))}, {F.bits(mean(hot, 'entropy'))} bits on average; the other {F.int(len(cold))} columns: " | |
| f"named {F.pct(mean(cold, 'top1_dec'))}, {F.bits(mean(cold, 'entropy'))} bits." | |
| if hot and cold else "No column has most of its calls in an RGP." if not hot else "Every column has most of its calls in an RGP."), | |
| glob=True, id="H") | |
| sp = [r for r in pl if r["locus"] in by_k and by_k[r["locus"]]["kind"] == "spine"] | |
| ot = [r for r in pl if r["locus"] in by_k and by_k[r["locus"]]["kind"] != "spine"] | |
| if top: F.hes_spine = by_k[top[0]["locus"]]["kind"] == "spine" | |
| if sp and ot: # what the @model paragraph is about: the two kinds of column compared | |
| a_s, e_s, a_o, e_o = mean(sp, "top1_dec"), mean(sp, "entropy"), mean(ot, "top1_dec"), mean(ot, "entropy") | |
| F.kind = dict(named_spine=a_s, named_other=a_o, bits_spine=e_s, bits_other=e_o) | |
| nm = ("more genes on the spine columns than on" if a_s > a_o + 0.02 else "fewer genes on the spine columns than on" | |
| if a_s < a_o - 0.02 else "about as many genes on the spine columns as on") | |
| hz = ("hesitates less there" if e_s < e_o - 0.02 else "hesitates more there" if e_s > e_o + 0.02 else "hesitates about as much there") | |
| pl_ = lambda n, w: f"{F.int(n)} {w} column" + ("" if n == 1 else "s") | |
| kt = by_k[top[0]["locus"]]["kind"] if top else None | |
| F.add(f"BY KIND OF COLUMN (computed, over the columns with calls): the {pl_(len(sp), 'spine')}: named {F.pct(a_s)}, " | |
| f"{F.bits(e_s)} bits on average; the {pl_(len(ot), 'fork and variable')}: named {F.pct(a_o)}, {F.bits(e_o)} bits. " | |
| f"The model names {nm} the fork and variable ones, and {hz}." | |
| + (f" Its most hesitant column, {F.int(top[0]['locus'])}, is a {kt} column" | |
| + (": the model can hesitate most inside a spine." if kt == "spine" else ".") if top else ""), glob=True, id="M") | |
| def span(r): return f"column {F.int(r['a'])}" if r["a"] == r["b"] else f"columns {F.int(r['a'])}-{F.int(r['b'])}" | |
| def spann(r): return span(r) + (f" ({F.int(r['b'] - r['a'] + 1)} columns)" if r["b"] > r["a"] else "") | |
| def rng(vals, f): | |
| lo, hi = f(min(vals)), f(max(vals)); return lo if lo == hi else f"{lo.rstrip('%')}-{hi}" | |
| def model(cs, listed=True): | |
| """the model on a stretch: named (range, mean), where it names fewest, where it hesitates most (or that it hardly does)""" | |
| w = [c for c in cs if c["r"]] | |
| if not w: return "no calls" | |
| n = sum(c["r"]["n"] for c in w); acc = sum(c["r"]["top1_dec"] * c["r"]["n"] for c in w) / n | |
| pk = max(w, key=lambda c: c["r"]["entropy"]); lo = min(w, key=lambda c: c["r"]["top1_dec"]) | |
| span_ = rng([c['r']['top1_dec'] for c in w], F.pct) | |
| s = f"named {span_}" + (f" (mean {F.pct(acc)})" if "-" in span_ else "") | |
| who = (lambda c: f" ({F.name(c['top'], k=c['k'])})") if listed else (lambda c: "") | |
| if len(w) == 1: return s + f"; {F.bits(pk['r']['entropy'])} bits" | |
| hb = pk["r"]["entropy"]; calm = hb < 0.2 | |
| if "-" in span_: | |
| F.low_cols.add(lo["k"]) | |
| if lo is pk and not calm: | |
| F.hes_cols.add(pk["k"]) | |
| return s + f", fewest named and most hesitant at column {F.int(lo['k'])}{who(lo)}, named {F.pct(lo['r']['top1_dec'])}, {F.bits(hb)} bits" | |
| s += f", fewest named at column {F.int(lo['k'])}{who(lo)}, named {F.pct(lo['r']['top1_dec'])}" | |
| F.hes_cols.add(pk["k"]) | |
| if calm: return s + f"; no column of this stretch hesitates more than {F.bits(hb)} bits (column {F.int(pk['k'])}{who(pk)})" | |
| return s + f"; most hesitant at column {F.int(pk['k'])}{who(pk)}, {F.bits(hb)} bits" | |
| def parts(cs): | |
| c = collections.Counter(PART.get(x["top"]["partition"], "?") for x in cs) | |
| return ("all " + next(iter(c))) if len(c) == 1 else ", ".join(f"{F.int(v)} {k}" for k, v in c.most_common()) | |
| group_prod = {} # one product for each run of named genes sharing a prefix (an operon, often): its most specific | |
| grp = [] | |
| for c in cols + [None]: | |
| n = c["top"] if c else None | |
| pre = n["label"][:3] if n and n.get("named") and not n["label"].startswith("y") and len(n["label"]) >= 4 else None | |
| if grp and pre and pre == grp[-1][1]["label"][:3] and c["k"] == grp[-1][0] + 1: grp.append((c["k"], n)); continue | |
| if len(grp) >= 2: # the product whose words the group shares most | |
| ws = [words(x.get("product", "")) for _, x in grp] | |
| sc = [sum(1 + sum(w in o for o in ws) - 1 for w in wi) for wi in ws] | |
| j = max(range(len(grp)), key=lambda j: (sc[j], -len(grp[j][1].get("product", "")))) | |
| if sc[j]: group_prod[id(grp[j][1])] = grp[j][1]["product"] | |
| grp = [(c["k"], n)] if pre else [] | |
| def prod(n): | |
| """a y-gene's product (its name says little), and one product per group of genes sharing a prefix; an unnamed | |
| family is named by its product already""" | |
| p = (n.get("product") or "").strip() | |
| if not n.get("named") or not p or p.lower() == "hypothetical protein": return "" | |
| if n["label"].startswith("y") or id(n) in group_prod: return f' "{F.text(p, 36)}"' | |
| return "" | |
| def fam_s(n, k, main=None, alt=False): | |
| same = " (another family)" if main is not None and n.get("named") and main.get("named") and n["label"] == main["label"] else "" | |
| sh = F.pct(n['n'] / NG); nm = F.name(n, k=k, alt=alt) | |
| if n.get("named"): F.gshare[n["label"]].add(Decimal(sh.rstrip("%"))) | |
| else: F.pshare[nm.strip('"').rstrip("…").lower()].add(Decimal(sh.rstrip("%"))) | |
| return f"{nm}{same} {PART.get(n['partition'], '?')} {sh}" | |
| def top_s(c): | |
| sh = F.pct(c["s1"]) | |
| if c["top"].get("named"): F.gshare[c["top"]["label"]].add(Decimal(sh.rstrip("%"))) | |
| return f"{F.name(c['top'], k=c['k'])}{prod(c['top'])} {sh}" | |
| E = {(e["s"], e["t"]): e["n"] for e in D.get("edges", [])} | |
| def branches(cs): | |
| """second families of consecutive fork columns that follow one another in most of their genomes: a parallel path""" | |
| out, cur = [], [] | |
| for c in cs: | |
| if cur and c["alts"]: | |
| p = cur[-1][1]; nx = max(c["alts"], key=lambda n: E.get((p["id"], n["id"]), 0)) | |
| if c["k"] == cur[-1][0] + 1 and E.get((p["id"], nx["id"]), 0) >= 0.5 * min(p["n"], nx["n"]): cur.append((c["k"], nx)); continue | |
| if len(cur) >= 3: out.append(cur) | |
| cur = [(c["k"], c["alts"][0])] if c["alts"] else [] | |
| if len(cur) >= 3: out.append(cur) | |
| return out | |
| # sections: each spine run, and each run of fork and variable columns between two spines (one header for them all) | |
| secs = []; BR = [] | |
| for r in runs: | |
| if r["kind"] != "spine" and secs and secs[-1]["kind"] == "open" and r["a"] == secs[-1]["b"] + 1: | |
| secs[-1]["b"] = r["b"]; secs[-1]["runs"].append(r) | |
| else: secs.append(dict(kind="spine" if r["kind"] == "spine" else "open", a=r["a"], b=r["b"], runs=[r])) | |
| for sec in secs: | |
| sec["cols"] = [c for r in sec["runs"] for c in r["cols"]] | |
| kinds = sorted({r["kind"] for r in sec["runs"]}) | |
| sec["what"] = "spine" if sec["kind"] == "spine" else {("fork",): "fork", ("variable",): "variable"}.get(tuple(kinds), "fork and variable columns") | |
| sec["rgp_mean"] = sum(c["rgp"] for c in sec["cols"]) / len(sec["cols"]) | |
| # the stretches most and least in regions of plasticity: the only ones to call so. Among three stretches or more, and | |
| # never one that covers most of the window ("the most plastic stretch" of 73 columns out of 80 says nothing) | |
| cand = [s for s in secs if len(s["cols"]) * 2 <= len(cols)] if len(secs) >= 3 else [] | |
| if len(cand) >= 2: | |
| hi = max(cand, key=lambda s: s["rgp_mean"]); lo = min(cand, key=lambda s: s["rgp_mean"]) | |
| if hi is not lo and hi["rgp_mean"] > lo["rgp_mean"] + 0.1: | |
| allp = min(c["rgp"] for c in lo["cols"]) >= 0.5 # nothing in the window is really outside: "least" says nothing | |
| F.plastic = dict(most=(hi["a"], hi["b"]), least=None if allp else (lo["a"], lo["b"])) | |
| F.add(f"PLASTICITY (the stretches below, by their mean over columns): the stretch most in regions of plasticity: {span(hi)} " | |
| f"({hi['what']}), RGP {rng([c['rgp'] for c in hi['cols']], F.pct)}; " | |
| + (f"every stretch of this window lies largely inside regions of plasticity (the lowest, {span(lo)}, RGP " | |
| f"{rng([c['rgp'] for c in lo['cols']], F.pct)}): never call a stretch of it the least plastic." | |
| if allp else f"the least: {span(lo)} ({lo['what']}), RGP {rng([c['rgp'] for c in lo['cols']], F.pct)}."), glob=True, | |
| cols=set(range(hi["a"], hi["b"] + 1)) | set(range(lo["a"], lo["b"] + 1)), id="P") | |
| F.add(f"STRETCHES, in window order (each spine; between spines, the fork and variable columns; the family listed is the " | |
| f"most carried at its column; share of {unit}; RGP: range over the columns):", glob=True, id="L") | |
| for i, sec in enumerate(secs, 1): | |
| cs = sec["cols"]; rgp = rng([c["rgp"] for c in cs], F.pct); ks = [c["k"] for c in cs] | |
| if sec["kind"] == "spine": | |
| genes = ", ".join(F.name(c["top"], k=c["k"]) + prod(c["top"]) for c in cs) | |
| F.add(f"{i}. {spann(sec)} spine: share {rng([c['s1'] for c in cs], F.pct)}, {parts(cs)}, RGP {rgp}. Genes: {genes}. {model(cs)}.", | |
| cols=ks, id=f"S{i}") | |
| continue | |
| h = F.add(f"{i}. {spann(sec)} {sec['what']}: share {rng([c['s1'] for c in cs], F.pct)}, {parts(cs)}, RGP {rgp}. " | |
| f"{model(cs, listed=False)}.", cols=ks, id=f"S{i}") | |
| inb = set() | |
| for r in sec["runs"]: | |
| for bb in (branches(r["cols"]) if r["kind"] == "fork" else []): | |
| ns = [n for _, n in bb]; inb |= {id(n) for n in ns}; BR.append((f"S{i}.b{bb[0][0]}", bb)) | |
| F.add(f" a second branch of {F.int(len(ns))} families through columns {F.int(bb[0][0])}-{F.int(bb[-1][0])}, a parallel path " | |
| f"that some {unit} carry instead of the most carried families there, each following the previous one in most of its " | |
| f"{unit}, share {rng([n['n'] / NG for n in ns], F.pct)}, {parts([dict(top=n) for n in ns])}; the branch's families: " | |
| + ", ".join(F.name(n, k=k, alt=True) for k, n in bb), cols=[k for k, _ in bb], head=h, | |
| id=f"S{i}.b{bb[0][0]}") | |
| mark = (lambda r: f" {r['kind']}") if sec["what"] == "fork and variable columns" else (lambda r: "") | |
| for r in sec["runs"]: | |
| if r["kind"] == "variable" and len(r["cols"]) > 4: # a long variable run: one line | |
| F.add(f" {span(r)}{mark(r)}, share {rng([c['s1'] for c in r['cols']], F.pct)}, {parts(r['cols'])}: " | |
| + ", ".join(top_s(c) for c in r["cols"]), | |
| cols=[c["k"] for c in r["cols"]], head=h, id=f"S{i}.c{r['cols'][0]['k']}-{r['cols'][-1]['k']}") | |
| continue | |
| for c in r["cols"]: | |
| al = [n for n in c["alts"][:3] if id(n) not in inb] | |
| alts = "; also " + ", ".join(fam_s(n, c["k"], c["top"], alt=True) for n in al) if al else "" | |
| F.add(f" {F.int(c['k'])}{mark(r)}: {fam_s(c['top'], c['k'])}{prod(c['top'])}{alts}", cols=[c["k"]], head=h, id=f"S{i}.c{c['k']}") | |
| groups_line(F, cols, secs, BR) | |
| # PRODUCTS: the informative products of named genes in groups sharing a prefix, and of the second families, not shown yet | |
| txt = F.txt(); cand = []; tops = [c["top"] for c in cols] | |
| for k, n in enumerate(tops): | |
| if not n.get("named") or n["label"].startswith("y"): continue | |
| if [x for x in tops[max(0, k - 1): k + 2] if x is not n and x.get("named") and x["label"][:3] == n["label"][:3]]: cand.append(n) | |
| cand += [a for c in cols for a in c["alts"] if a.get("named")] | |
| seen, items = set(), [] | |
| for n in cand: | |
| lab, p = n["label"], (n.get("product") or "").strip() | |
| if lab in seen or not informative(p) or f'{lab} "' in txt: continue | |
| seen.add(lab); items.append(f"{lab} {F.text(p, 40)}") | |
| if items: F.add("PRODUCTS of named genes (for [[ ]]): " + "; ".join(items[:40]), glob=True, id="X") | |
| last = cols[-1] if cols else None | |
| if last: F.add(f"END: the window ends at column {F.int(last['k'])}, {F.name(last['top'], k=last['k'])}, in {F.pct(last['s1'])} of {unit}.", | |
| cols=[last["k"]], glob=True, id="E") | |
| return F | |
| # ---------------------------------------------------------------- the prompt | |
| STYLE = """**The spine starts at the origin.** Columns 0 to 5, `dnaA`, `dnaN`, `recF`, `gyrB`, `yidB` and `yidA`, sit in essentially every genome, persistent, and hardly ever in a region of plasticity: 0 to 0.5% of calls. The model names `dnaN`, `recF` and `gyrB` in 99.7 to 100% of genomes, `yidB` in 93% and `yidA` in 99%. | |
| **The first fork is at `yidX`**, column 6. Then the `dgo` operon, [[galactonate catabolism]], variable columns 7 to 11: about 85% of genomes carry it, and panRGP puts it in a region of plasticity in 90% of them. The model is not lost there: it names each gene in 87 to 99% of genomes, with hardly any hesitation. A variable region can keep a predictable internal order. | |
| **Then a long persistent stretch**, `cbrA` to `yicS` through the `ilv` and `uhp` operons, where the model names the next gene in 92% of genomes and seldom hesitates. Around columns 42 to 52 the graph opens: 50% of the calls there fall in a region of plasticity, and the model's hesitation peaks, up to 1.4 bits. Some genomes carry [[type III secretion genes]] there, others [[sugar transport genes]]. | |
| **The window ends in the `waa` locus**, [[which builds the lipopolysaccharide core]]: `waaA` in most genomes, then a spread of glycosyltransferases.""" | |
| TAGS = dict(start="the first stretch", variable="the middle: fork and variable stretches, branches, gene groups", | |
| model="[M], then the most hesitant column of [H]", end="the last stretch and [E]", whole="[G], then [C]") | |
| CONTRACT = f"""You write the reading of one window of an E. coli pangenome graph for a scientific web page, from facts the server computes for it: Bacformer, a genomic language model, reads chromosomes gene by gene and at each gene calls the next one; a window is 80 columns of genes. Follow the skill below. | |
| FORM. Exactly {len(SECTIONS)} paragraphs in this order, one blank line apart, each opening with its tag and a short bold sentence in **double asterisks**: """ + "; ".join(f"@{t} ({w})" for t, w in TAGS.items()) + f""". Two sentences each (@variable may take three): {WORDS_MIN_ASK} to {WORDS_TARGET} words in all, at most 3 numbers a sentence. Beyond {WORDS_MAX} words whole sentences are cut. | |
| CITATIONS. Each facts line begins with its id: [W], [G], [M], [S3], [S3.c12], [S4.b45]. Each sentence ends with the ids of the lines it is written from, in braces, before its full stop: "The model names 93% of them {{S1}}." Every number, gene name, product word and column of a sentence must stand in those lines, or the sentence is dropped. A stretch line [S4] gives its ranges and lets you name the genes, columns and shares of its sub-lines: for columns 15 to 20 of it, their lowest and highest share exactly, or "from A to B%", the first and the last. A branch: its own line and clause. Two or three ids a sentence, {CITES_MAX} at most. | |
| RULES. 1. Numbers as the facts write them, in digits (bits may be rounded to 1 decimal); a range as "92 to 99%"; a share, a share in regions of plasticity and a share named are three different numbers. 2. Gene names in backticks; an operon by its prefix (`dgo`); more than 6 in a row as `first` to `last`; an unnamed family by its product, without backticks. 3. Biology only inside [[ ]]: 2 to 6 words from the products of the lines cited, true of most families at the columns named; outside the brackets no product word, function, pathway or cause, not even in bold. 4. Superlatives only as [P], [H] and the stretch lines state them. Write "in a region of plasticity in N% of calls", never "RGP", "share" or the letters P, S, C; spine, fork and variable as the facts label them. Never call a persistent family rare, never say chromosomes lack a gene. 5. [C] and [M] in their direction; never "readily", never "where the graph opens" unless [M] and [H] say so. 6. Plain, exact English; no headings, lists or other markup; nothing about yourself or the facts; no sentence of the reference readings, their windows are not yours.""" | |
| _SKILL = [None, None, None] | |
| def skill(): | |
| """the skill document (reading_skill.md beside this file, READING_SKILL for another path) and its sha, read once: | |
| the domain notes, the procedure and the reference reading the model works by. The sha is part of the cache key, so | |
| a changed skill makes new readings. Without the file the prompt keeps the reference reading alone.""" | |
| if _SKILL[2] is None: | |
| try: t = open(SKILL_PATH, encoding="utf-8").read().strip() | |
| except OSError: t = "" | |
| _SKILL[2] = bool(t) | |
| if not t: t = ("# Reading a window of the pangenome graph — the skill document is not on this server\n\nThe reference " | |
| "reading, the hand-written one of the dnaA window (its numbers and its biology belong to that window, " | |
| f"not to yours):\n\n{STYLE}") | |
| _SKILL[0] = t; _SKILL[1] = hashlib.sha1(t.encode()).hexdigest()[:12] | |
| return _SKILL[0], _SKILL[1] | |
| def system_msg(): | |
| t, sha = skill() | |
| return f"{CONTRACT}\n\n{t}", sha | |
| def user_msg(F, wid): | |
| extra = ("\n\nThis is the dnaA window, the one of the first reference reading: write your own reading from these facts; any " | |
| "number that is not in the facts is rejected.") if wid == DNAA else "" | |
| return (f"Facts, one line each with its id:\n{F.txt()}{extra}\n\nWrite the reading: " | |
| + " ".join(f"@{t}" for t in SECTIONS) + ", each sentence ending with the ids of the facts lines it is written from.") | |
| # ---------------------------------------------------------------- slips of form, put right without a new call | |
| PARTN = {"P": "persistent", "S": "shell", "C": "cloud"} | |
| def tidy(text, F): | |
| """the model's text with its slips of form put right: no headings or list markers, ranges as "A to B", "RGP" and | |
| "share" in words, the letters P/S/C spelled out, a row of more than 6 genes as `first` to `last`""" | |
| t = re.sub(r"(?m)^\s*#+\s*", "", text.strip()) | |
| t = re.sub(r"(?<=\d)[\u202f\u00a0\u2009](?=\d{3}\b)", ",", t) | |
| t = re.sub(r"(?m)^\s*(?:[-•]|\d+\.)\s+", "", t) | |
| t = re.sub(r"(?m)^\s*\*\s+(?!\*)", "", t) | |
| segs = re.split(r"(`[^`\n]*`)", t) | |
| num = r"\d[\d,]*(?:\.\d+)?" | |
| for i, s in enumerate(segs): | |
| if s.startswith("`") and s.endswith("`") and len(s) > 1: continue | |
| s = re.sub(rf"(?<![\w.,\-–])({num})\s?[-–]\s?({num})(?![\w\-–]|\.\d)", r"\1 to \2", s) | |
| s = re.sub(r"\b(inside|outside|within|in) (any|an) RGPs?\b", lambda m: f"{m.group(1)} {'any' if m.group(2) == 'any' else 'a'} region of plasticity", s) | |
| s = re.sub(r"\b(inside|outside|within|in) RGPs\b", r"\1 regions of plasticity", s) | |
| s = re.sub(rf"\b(?:with |at )?RGP (?:of |at |is |was )?({num}(?: to {num})?%)", r"\1 in a region of plasticity", s) | |
| s = re.sub(r"\ban RGP\b", "a region of plasticity", s) | |
| s = re.sub(r"\bthe RGPs\b", "the regions of plasticity", s) | |
| s = re.sub(r"\bthe RGP\b", "the region of plasticity", s) | |
| s = re.sub(r"\b(in|within) RGPs?\b", r"\1 regions of plasticity", s) | |
| s = re.sub(r"(?<![\w-])RGPs?\b", lambda m: "the share in regions of plasticity", s) | |
| s = re.sub(rf"\b(?:at |with |by |of )?(?:a )?share (?:of )?({num}(?: to {num})?%)", rf"in \1 of {F.unit}", s) | |
| s = re.sub(rf"({num}(?: to {num})?%) shares?\b", rf"\1 of {F.unit}", s) | |
| s = re.sub(r"\ball ([PSC])\b(?=[,.;:)]|\s+and\b|\s+families|\s*$)", lambda m: "all " + PARTN[m.group(1)], s) | |
| s = re.sub(r"(?<![\w.])(\d+) ([PSC])\b(?=[,.;:)]|\s+and\b)", lambda m: f"{m.group(1)} {PARTN[m.group(2)]}", s) | |
| segs[i] = s | |
| t = "".join(segs) | |
| t = re.sub(r"`([^`\n]*?)([.,;:!?]?\*\*)`", r"`\1`\2", t) # "`mutS.**`": the bold's end inside the gene's backticks | |
| t = re.sub(r"(^|[.;:!?]\s+|\*\*|\n)the share in regions", lambda m: m.group(1) + "The share in regions", t) | |
| G = r"`[^`\n]+`" | |
| def rows(m): | |
| items = re.findall(G, m.group(0)) | |
| return f"{items[0]} to {items[-1]}" if len(items) > 6 else m.group(0) | |
| t = re.sub(rf"{G}(?:,\s+{G}){{5,}}(?:,?\s+and\s+{G})?", rows, t) | |
| t = re.sub(r"[ \t]{2,}", " ", t) | |
| return t.strip() | |
| # ---------------------------------------------------------------- the checker | |
| NUMW = dict(two=2, three=3, four=4, five=5, six=6, seven=7, eight=8, nine=9, ten=10, eleven=11, twelve=12, thirteen=13, fourteen=14, | |
| fifteen=15, sixteen=16, seventeen=17, eighteen=18, nineteen=19, twenty=20, thirty=30, forty=40, fifty=50, sixty=60, | |
| seventy=70, eighty=80, ninety=90) | |
| NUMW_RE = re.compile(r"\b(" + "|".join(NUMW) + r")\b", re.I) | |
| VAGUE_RE = re.compile(r"\b(hundreds?|thousands?|dozens?|half|halves|quarters?|twice|thrice|tenths?|(?:a|one|two)\s+thirds?|" | |
| r"(?:nine|eight|seven|six|five|four|three|two|one) (?:in|out of) (?:ten|five|four|three|two))\b", re.I) | |
| NUM_RE = re.compile(r"(?<![\w.,])(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?(?![\w]|\.\d|,\d{3})") | |
| META_RE = re.compile(r"\b(?:COMPARISON|HESITATION|STRETCHES|WHOLE WINDOW|TERMS|PLASTICITY|PRODUCTS|GROUPS)\b|" | |
| r"\b(?i:the facts|as stated|products agree|whose products|the group of \[)") | |
| UNIT_RE = re.compile(r"\s*(%|percent\b|per cent\b|-?bits?\b|famil(?:y|ies)\b|(?:complete\s+)?(?:genomes?|chromosomes?)\b)", re.I) | |
| LOWER_RE = re.compile(r"\s*(?:to|-|–)\s*(\d[\d,]*(?:\.\d+)?)\s*(%|percent\b|per cent\b|bits?\b)", re.I) | |
| IDENT_RE = re.compile(r"[\w'\-/.]*\d[\w'\-/.]*") | |
| GENE_RE = re.compile(r"(?<![\w`])([a-z]{3}[A-Z][A-Za-z0-9_]*)(?![\w])") | |
| PROT_RE = re.compile(r"(?<![\w`])([A-Z][a-z]{2}[A-Z][0-9]?)(?![\w])") | |
| OPERON_RE = re.compile(r"(?<![\w`])([a-z]{3}[A-Z]?)\s+(?:operon|genes|locus|loci|cluster)\b") | |
| WORDS3 = set("""the all its few new two six ten one any are old big odd set top end our key own net raw lac far low how who | |
| why but not nor yet per via has had was can may use may six ten and for hold holds carry carries same both that this those | |
| these more less than into onto over such very each many most some only then when where which while with from run runs""".split()) | |
| ALLOWED_WORDS = {"panRGP", "PanGBank", "PPanGGOLiN", "Bacformer"} | |
| NEG_RE = re.compile(r"\b(lacks?|lacking|absent|missing|do(?:es)? not carry|carry none)\b", re.I) | |
| LEAST_RE = re.compile(r"hesitat\w*\s+(?:is\s+)?(?:the\s+)?(?:least|lowest)|least hesitant|lowest hesitation|hesitation is lowest|lowest in hesitation", re.I) | |
| SUP_RE = re.compile(r"\b(?:(?:most|least) (?:variable|certain|uncertain|sure|unsure|confident|predictable|unpredictable|stable|" | |
| r"conserved|reliable|difficult|hard|easy|surprising|open)|surest|hardest|easiest)\b", re.I) | |
| BIO_RE = re.compile(r"\b(envelope|respirat\w*|ribosom\w*|nitrate|nitrite|energy|metaboli\w*|transport\w*|synthes\w*|biosynth\w*|catabol\w*|" | |
| r"flagell\w*|fimbri\w*|pil(?:us|i)|secret\w*|lipopolysacch\w*|lipid\w*|sugars?|iron|sulfur|stress|toxins?|prophage|phage|" | |
| r"motility|chemotaxis|division|replication|translation|transcription\w*|repair|membranes?|peptidoglycan|capsul\w*|" | |
| r"biofilm|virulence|pathogen\w*|vitamins?|cofactors?|hydrogenase|chaperones?|efflux|resistance|antibiotics?|" | |
| r"housekeeping|essential|enzym\w*|amino acids?|nucleotides?|carbon|nitrogen|phosphate|osmotic|adhesion|defen[cs]e)\b", re.I) | |
| PLASTIC_RE = re.compile(r"\b(most|least) (plastic|in regions? of plasticity)\b", re.I) | |
| ONLY_RE = re.compile(r"\bonly (?:in |about |around )?(\d+(?:\.\d+)?)\s?%", re.I) # "only 89%": "only" before a majority share | |
| def _only_majority(text): | |
| """"only NN%" with NN >= 50: a majority called small (the checker drops it)""" | |
| return [dict(kind="claim", text=m.group(0), why="\u201conly\u201d before a majority share") | |
| for m in ONLY_RE.finditer(text) if float(m.group(1)) >= 50] | |
| HES_RE = re.compile(r"most hesitant|hesitates? (?:the )?most|hesitation peaks|peak hesitation|hesitation is highest|highest hesitation", re.I) | |
| LOW_RE = re.compile(r"\bfewest\b|\blowest at\b|least often named|names? (?:the )?fewest|named (?:the )?least", re.I) | |
| READY_RE = re.compile(r"\breadily\b|\bwith ease\b|\beasily\b|\bwithout (?:trouble|difficulty|hesitation)\b|\bhas no trouble\b", re.I) | |
| OPENS_RE = re.compile(r"where the graph opens|where it opens|where the graph is open|where chromosomes differ|where the order varies|" | |
| r"\b(?:in|on|at|across) the (?:fork|variable|plastic|open)\w*|\b(?:in|on|at|across) the fork and variable\b", re.I) | |
| PROSE = set("""region regions plasticity model column columns share shares named naming names hesitates hesitation hesitant bits | |
| perplexity family families gene genes call calls chromosome chromosomes genome genomes persistent shell cloud spine spines fork | |
| forks variable branch branches path paths parallel stretch stretches window windows anchor direction carried carries second first | |
| most least other others unnamed average mean readily window's columns' opens""".split()) | |
| ALIAS = dict(waa=("rfa",), rfa=("waa",)) | |
| OPERON_RE2 = re.compile(r"`([a-z]{3})[A-Z]?[A-Za-z0-9]*`\s+(?:operon|genes|locus|loci|cluster|region)\b") | |
| def roundings(v, q0=0): | |
| """a fact number and the same number rounded to fewer decimals (at least q0)""" | |
| out = {v}; e = -v.as_tuple().exponent | |
| for q in range(q0, max(e, 0)): | |
| for mode in (ROUND_HALF_UP, ROUND_HALF_EVEN): out.add(v.quantize(Decimal(1).scaleb(-q), rounding=mode)) | |
| return out | |
| def _allowed(nums): | |
| """the numbers a text may write for the facts' numbers: rounded further, but bits to 1 decimal at least (the text's | |
| whole-number bits are refused apart), and never to 0% or 100% unless the facts show it""" | |
| a = {k: set().union(*(roundings(v, 1 if k == "bits" else 0) for v in vs)) if vs else set() for k, vs in nums.items()} | |
| if "pct" in a: a["pct"] = {x for x in a["pct"] if x not in (0, 100) or x in nums["pct"]} | |
| return a | |
| _LNUM = re.compile(r"(?<![\w.,])(\d+(?:\.\d+)?)(?:\s*(%)|\s*(bits?)\b)") | |
| _LRANGE = re.compile(r"(?<![\w.,])(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)(%)") | |
| def _line_index(F): | |
| """per facts line: its words, and its percent and bits numbers (a range's lower end is a percent)""" | |
| if getattr(F, "_idx", None) is not None: return F._idx | |
| idx = [] | |
| for ln, inf in zip(F.lines, F.info): | |
| nums = collections.defaultdict(set) | |
| for mm in _LRANGE.finditer(ln): nums["pct"].add(Decimal(mm.group(1))) | |
| for mm in _LNUM.finditer(ln): nums["pct" if mm.group(2) else "bits"].add(Decimal(mm.group(1))) | |
| idx.append(dict(inf, words=set(re.findall(r"[\w'-]+", ln)), nums=nums)) | |
| F._idx = idx; return idx | |
| def sentences(text): | |
| """paragraphs, each a list of sentences (bold marks dropped)""" | |
| out = [] | |
| for p in re.split(r"\n\s*\n", text.replace("**", "").strip()): | |
| out.append([s for s in re.split(r"(?<=[.;])\s+(?=[A-Z`\[\"])", " ".join(p.split())) if s]) | |
| return out | |
| def cols_of(s, W=80): | |
| """the columns a sentence names: "column 5", "columns 3 to 9", "columns 3, 4 and 7", "fork columns, 51 to 79", | |
| "the last 29 columns", "the first 6 columns" """ | |
| cs = set() | |
| for m in re.finditer(r"\bcolumns?,\s+(\d+)\s+to\s+(\d+)(?![.,]\d|\s*%)", s, re.I): cs |= set(range(int(m.group(1)), int(m.group(2)) + 1)) | |
| for m in re.finditer(r"\bcolumn,\s+(\d+)\b(?![.,]\d|\s*(?:%|percent|bits?\b)|\s+to\b)", s, re.I): cs.add(int(m.group(1))) | |
| for m in re.finditer(r"\b(first|last) (\d+) columns\b", s, re.I): | |
| n = min(int(m.group(2)), W); cs |= set(range(0, n)) if m.group(1).lower() == "first" else set(range(W - n, W)) | |
| for m in re.finditer(r"\bcolumns? ((?:\d+(?![.,]\d)(?:\s*(?:,|and|to|-|–)\s*(?=\d))?)+)", s, re.I): | |
| nums = [int(x) for x in re.findall(r"\d+", m.group(1))] | |
| seps = re.findall(r"\d+\s*(to|-|–|,|and)\s*(?=\d)", m.group(1)) | |
| val = {int(x.group(0)) for x in re.finditer(r"\d+", m.group(1)) # "fewest at column 6, 72%", "columns 61 to 79, 4 to 77%": | |
| if re.match(r"\s*(?:(?:to|-|–)\s*\d[\d,]*(?:\.\d+)?\s*)?(?:%|percent|per cent|bits?\b|famil)", # a value, not a column | |
| s[m.start(1) + x.end():])} | |
| cs |= set(nums) - val | |
| for (a, b), sep in zip(zip(nums, nums[1:]), seps): | |
| if sep in ("to", "-", "–") and b >= a and a not in val and b not in val: cs |= set(range(a, b + 1)) | |
| return cs | |
| def genes_of(s, F): | |
| g = {x.strip() for x in re.findall(r"`([^`\n]+)`", s)} | |
| for x in F.genes: | |
| if len(x) > 2 and re.search(rf"(?<![\w`]){re.escape(x)}(?![\w])", s): g.add(x) | |
| return g | |
| def _gene_lines(g, F, idx): | |
| pats = {g} if g in F.genes else {x for x in F.genes if len(g) == 3 and x.startswith(g)} or {g} | |
| return {i for i, L in enumerate(idx) if L["words"] & pats} | |
| def _scope(s, F, idx): | |
| """the facts lines a sentence points to: those of its genes and columns, the lines between `a` and `b` of a run, | |
| and each one's stretch line""" | |
| genes = genes_of(s, F); cols = cols_of(s); hit = set(); low = s.lower() | |
| for p, ks in F.atp.items(): # an unnamed family named by its product | |
| if len(p) >= 6 and p.rstrip("…") in low: cols = cols | ks | |
| for g in genes: hit |= {i for i in _gene_lines(g, F, idx) if not idx[i]["glob"]} | |
| for a, b in re.findall(r"`([^`]+)`\s+(?:to|through)\s+`([^`]+)`", s): | |
| ia = [i for i in _gene_lines(a, F, idx) if not idx[i]["glob"]]; ib = [i for i in _gene_lines(b, F, idx) if not idx[i]["glob"]] | |
| if ia and ib: hit |= set(range(min(ia), max(ib) + 1)) | |
| for i, L in enumerate(idx): | |
| if L["cols"] & cols and not L["glob"]: hit.add(i) | |
| heads = {idx[i]["head"] for i in hit if idx[i]["head"] is not None} | |
| gcols = set().union(*(F.at.get(g, set()) for g in genes)) if genes else set() | |
| return hit | heads, cols | gcols | |
| def check(text, F): | |
| """the whole-text check (r7), kept for comparison with the per-sentence one (pg_reading.py check prints both, and it | |
| reads a text that carries no citations) -> dict(ok, numbers, names, words, bad=[...], hints=[...]): every number, gene | |
| name, number word and identifier with a digit of text, each number against the facts lines of its sentence's genes and | |
| columns, and the claims the facts make. Nothing ties a number to the line the sentence is written from: that is what | |
| check_cited does.""" | |
| ftxt = F.txt(); flow = ftxt.lower(); idx = _line_index(F) | |
| allowed = _allowed(F.nums) | |
| anyv = set().union(*allowed.values()) if allowed else set() | |
| bad = []; nnum = nname = 0 | |
| body = text | |
| for fr in sorted(F.frags, key=len, reverse=True): # "family 4" of a product: a name, not a number | |
| body = re.sub(re.escape(fr), lambda mm: re.sub(r"\d", "#", mm.group(0)), body, flags=re.I) | |
| interp_spans = [mm.span() for mm in re.finditer(r"\[\[.*?\]\]", body, flags=re.S)] | |
| # identifiers with a digit and letters (IS3, 16S, DUF1234, 2-dehydro-3-deoxy...) must be in the facts; inside [[ ]] a | |
| # chemical name with a locant (2-oxoglutarate) is allowed | |
| def ident(mm): | |
| tok = mm.group(0).strip("'-/.") | |
| if not re.search(r"[A-Za-z]", tok): return mm.group(0) | |
| u = re.fullmatch(r"(\d[\d,]*(?:\.\d+)?)-(bits?|fold|columns?|genes?|famil(?:y|ies)|percent)", tok) | |
| if u: return u.group(1) + " " + u.group(2) # 0.4-bit: a number and its unit | |
| inside = any(a <= mm.start() < b for a, b in interp_spans) | |
| chem = re.fullmatch(r"\d+(?:,\d+)*-[A-Za-z]{4,}[\w,-]*", tok) and not re.match(r"\d+(?:,\d+)*-(?:genes?|columns?|famil|fold|bits?|stretch)", tok) | |
| if tok.lower() not in flow and not (inside and chem): bad.append(dict(kind="identifier", text=tok, why="not in the facts")) | |
| return re.sub(r"\d", "#", mm.group(0)) | |
| body = IDENT_RE.sub(ident, body) | |
| for mm in NUM_RE.finditer(body): | |
| raw = mm.group(0); nnum += 1 | |
| try: v = Decimal(raw.replace(",", "")) | |
| except InvalidOperation: continue | |
| u = UNIT_RE.match(body, mm.end()); unit = (u.group(1).lower() if u else "") | |
| if not unit: # a range's lower end: the unit of its upper end | |
| lo = LOWER_RE.match(body, mm.end()) | |
| if lo: unit = lo.group(2).lower() | |
| kind = "pct" if unit in ("%", "percent", "per cent") else "bits" if "bit" in unit else "fam" if unit.startswith("famil") and "." in raw else None | |
| ok = v in (allowed.get(kind, set()) if kind else anyv) | |
| if ok and kind == "bits" and "." not in raw and v not in F.nums["bits"]: | |
| bad.append(dict(kind="format", text=f"{raw} bits", why="bits rounded to a whole number: write them as the facts do")); continue | |
| if not ok: | |
| ctx = body[max(0, mm.start() - 30): mm.end() + 12].replace("\n", " ") | |
| bad.append(dict(kind="number", text=raw + ("%" if kind == "pct" else f" {unit}" if unit else ""), | |
| why=f"not a {dict(pct='percent', bits='bits value', fam='families value').get(kind, 'number')} of the facts", context=ctx.strip())) | |
| plain = re.sub(r"\[\[.*?\]\]", " ", text, flags=re.S) | |
| for mm in re.finditer(r"(?<![\w.-])\.\d+\b|(?<![\w,-])\d+,\d{1,2}\b(?!,?\d)", plain): | |
| bad.append(dict(kind="format", text=mm.group(0), why="write numbers as the facts do (0.97, not .97 or 0,97)")) | |
| for mm in META_RE.finditer(text): | |
| bad.append(dict(kind="wording", text=mm.group(0), why="the reading speaks of the window, not of the facts")) | |
| for mm in NUMW_RE.finditer(text): | |
| nnum += 1 | |
| if Decimal(NUMW[mm.group(1).lower()]) not in anyv: bad.append(dict(kind="number word", text=mm.group(0), why="not a number of the facts")) | |
| for mm in VAGUE_RE.finditer(text): | |
| bad.append(dict(kind="number word", text=mm.group(0), why="write numbers in digits, from the facts")) | |
| genes = F.genes | |
| def known(g): | |
| return g in genes or (g.isalpha() and g.islower() and len(g) == 3 and any(x.startswith(g) for x in genes)) | |
| for mm in re.finditer(r"`([^`\n]+)`", text): | |
| g = mm.group(1).strip(); nname += 1 | |
| if not known(g) and not re.search(rf"(?<!\w){re.escape(g.lower())}(?!\w)", flow) and \ | |
| not any(k.endswith("…") and g.lower().startswith(k[:-1]) for k in F.atp): # a product of the facts in backticks: a slip of form | |
| bad.append(dict(kind="gene", text=g, why="not a gene of the facts")) | |
| nog = re.sub(r"`[^`\n]*`", " ", text) | |
| for mm in GENE_RE.finditer(nog): | |
| g = mm.group(1); nname += 1 | |
| if g not in genes and g not in ALLOWED_WORDS and g not in ftxt: bad.append(dict(kind="gene", text=g, why="not a gene of the facts")) | |
| for mm in PROT_RE.finditer(nog): | |
| g = mm.group(1) | |
| if g[0].lower() + g[1:] not in genes and g not in ftxt and g not in ALLOWED_WORDS: | |
| nname += 1; bad.append(dict(kind="gene", text=g, why="not a gene or product of the facts")) | |
| for mm in OPERON_RE.finditer(re.sub(r"\[\[.*?\]\]", " ", nog)): | |
| g = mm.group(1) | |
| if not known(g) and g.lower() not in WORDS3 and not re.search(rf"(?<![\w`]){g}(?![\w])", ftxt): | |
| bad.append(dict(kind="gene", text=g, why="not a gene or operon of the facts")) | |
| bad += _claims(text, F, idx) | |
| seen = set(); uniq = [] | |
| for b in bad: | |
| k = (b["kind"], b["text"]) | |
| if k not in seen: seen.add(k); uniq.append(b) | |
| return dict(ok=not uniq, numbers=nnum, names=nname, words=len(re.findall(r"[A-Za-z0-9][\w'.,%-]*", text.replace("**", ""))), | |
| bad=uniq, hints=interp_hints(text, F)) | |
| def _claims(text, F, idx): | |
| """numbers against their sentence's lines, genes against their columns, the comparison's direction, and the claims | |
| the facts do not make""" | |
| bad = []; allF = _allowed(F.nums); flow = F.txt().lower() | |
| glob = collections.defaultdict(set) | |
| for L in idx: | |
| if L["glob"]: | |
| for k, v in L["nums"].items(): glob[k] |= v | |
| for para in sentences(text): | |
| prev = set() | |
| for j, s in enumerate(para): | |
| hit, cols = _scope(s, F, idx) | |
| use = (hit | prev) if (hit or prev) else set(range(len(idx))) | |
| pool = collections.defaultdict(set) | |
| for i in use: | |
| for k, v in idx[i]["nums"].items(): pool[k] |= v | |
| for k in glob: pool[k] |= glob[k] | |
| al = _allowed(pool) | |
| s_ = re.sub(r"\[\[.*?\]\]", " ", s) | |
| for m in re.finditer(r"(?<![\w.,])(\d+(?:\.\d+)?)(?=\s*(%|percent\b|per cent\b|bits?\b))", s_): | |
| v = Decimal(m.group(1)); k = "bits" if m.group(2).startswith("bit") else "pct" | |
| if v not in al.get(k, set()) and v in allF.get(k, set()): | |
| bad.append(dict(kind="scope", text=m.group(0) + ("%" if k == "pct" else " bits"), | |
| why="a number of another stretch or column than the ones its sentence names", context=s[:160])) | |
| for m in re.finditer(r"(?<![\w.,])(\d+(?:\.\d+)?)(?=\s*(?:to|-|–)\s*\d+(?:\.\d+)?\s*%)", s_): | |
| v = Decimal(m.group(1)) | |
| if v not in al.get("pct", set()) and v in allF.get("pct", set()): | |
| bad.append(dict(kind="scope", text=m.group(0) + "% (a range's lower end)", | |
| why="a number of another stretch or column than the ones its sentence names", context=s[:160])) | |
| for rx, where, what in ((HES_RE, F.hes_cols, "most hesitant"), (LOW_RE, F.low_cols, "fewest named")): | |
| for m in rx.finditer(s): | |
| if what == "most hesitant" and re.search(r"\bno column\b|hardly|barely|seldom", s, re.I): continue | |
| cc = _claim_cols(s, m, F) or cols | |
| if cc and not (cc & where): | |
| bad.append(dict(kind="claim", text=m.group(0), why=f"the facts do not state that column as the {what}", context=s[:160])) | |
| for m in PLASTIC_RE.finditer(s): | |
| w = m.group(1).lower(); ab = F.plastic.get("most" if w == "most" else "least") | |
| if not ab: bad.append(dict(kind="claim", text=m.group(0), why="the facts name no stretch so", context=s[:160])); continue | |
| cc = cols or (_scope(para[j + 1], F, idx)[1] if j + 1 < len(para) else set()) # a bold opener: the next sentence | |
| if cc and not (cc & set(range(ab[0], ab[1] + 1))): | |
| bad.append(dict(kind="claim", text=m.group(0), why=f"the facts say so of columns {ab[0]} to {ab[1]} only", context=s[:160])) | |
| prev = hit | |
| bad += _colgene(text, F) | |
| bad += _direction(text, F) | |
| for m in NEG_RE.finditer(re.sub(r"\[\[.*?\]\]", " ", text)): | |
| bad.append(dict(kind="claim", text=m.group(0), why="the facts never say chromosomes lack a gene")) | |
| for m in re.finditer(r"outside (?:any|a) regions? of plasticity,? in (?:only )?\d[\d.]*(?: to \d[\d.]*)?%", text, re.I): | |
| bad.append(dict(kind="claim", text=m.group(0), why="the facts give the share of calls inside a region of plasticity")) | |
| for m in SUP_RE.finditer(re.sub(r"\[\[.*?\]\]", " ", text)): bad.append(dict(kind="claim", text=m.group(0), why="a superlative the facts do not state")) | |
| bad += _only_majority(re.sub(r"\[\[.*?\]\]", " ", text)) | |
| for m in re.finditer(r"\*\*(.+?)\*\*", text, re.S): # the bold sentences: structure only | |
| b = next((x for x in BIO_RE.finditer(re.sub(r"\[\[.*?\]\]|`[^`]*`|\"[^\"]*\"", " ", m.group(1))) | |
| if x.group(0).lower() not in flow), None) | |
| if b: bad.append(dict(kind="claim", text=b.group(0), why="biology outside [[ ]], in a bold sentence", context=m.group(1)[:120])) | |
| for m in LEAST_RE.finditer(text): bad.append(dict(kind="claim", text=m.group(0), why="the facts give the most hesitant columns, not the least")) | |
| for m in re.finditer(r"\b\d+\s+(?:in|out of|of every)\s+\d+\b(?!\s*%|\.\d|\s+to\s+\d)", re.sub(r"\[\[.*?\]\]", " ", text)): | |
| bad.append(dict(kind="claim", text=m.group(0), why="a fraction the facts do not give")) | |
| return bad | |
| def _claim_cols(s, m, F): | |
| """the columns a claim ("hesitates most at ...", "... the fewest named") is about: what follows it up to the next | |
| punctuation, else nothing (the caller takes the sentence's)""" | |
| obj = re.split(r"[;.]", s[m.end(): m.end() + 60])[0] | |
| cc = cols_of(obj) | |
| for g in re.findall(r"`([^`\n]+)`|\"([^\"\n]+)\"", obj): | |
| g = (g[0] or g[1]).strip() | |
| cc |= F.at.get(g, set()) | F.atp.get(g.lower(), set()) | |
| for g in F.genes: | |
| if re.search(rf"(?<![\w`]){re.escape(g)}(?![\w])", obj): cc |= F.at.get(g, set()) | |
| return cc | |
| def _colgene(text, F): | |
| """a gene named next to a column number must sit at that column in the facts (most carried, second family or branch)""" | |
| bad = [] | |
| C = r"columns?,? (\d+)(?:\s*(?:to|-|–)\s*(\d+))?" | |
| G = r"`([^`]+)`(?:\s+to\s+`([^`]+)`)?" | |
| pats = [(rf"\b{C},?\s+(?:\(|the\s+)?{G}", "cg"), (rf"{G},?\s+(?:\(|in |at ){C}", "gc")] | |
| for para in sentences(text.replace("**", "")): | |
| for s in para: | |
| for pat, order in pats: | |
| for m in re.finditer(pat, s, re.I): | |
| if order == "cg": | |
| a, b, g1, g2 = m.group(1), m.group(2), m.group(3), m.group(4) | |
| if re.match(r"\s*,?\s*(?:at|in|through|across|over|column|columns)\s+(?:columns?\s+)?\d", s[m.end():]) or \ | |
| re.search(r"\b(?:between|into|from)\s+$", s[max(0, m.start() - 20): m.start()]): continue | |
| else: | |
| g1, g2, a, b = m.group(1), m.group(2), m.group(3), m.group(4) | |
| if re.match(r"\s*(?:,|and)\s*\d", s[m.end():]): continue # a list of columns: one for each gene | |
| if re.search(r"columns?\s+\d+(?:\s*(?:to|-|–)\s*\d+)?\s*,?\s*$", s[max(0, m.start() - 20): m.start()], re.I): continue | |
| a = int(a); b = int(b or a); single = (m.group(2) if order == "cg" else m.group(4)) is None | |
| for g, end in ((g1, a), (g2, b)): | |
| if not g or g not in F.at or (g is g2 and single): continue | |
| want = {end} if (g2 and not single) else set(range(a, b + 1)) | |
| if not (want & F.at[g]): | |
| bad.append(dict(kind="column", text=m.group(0), why=f"`{g}` is at column {', '.join(map(str, sorted(F.at[g])))} in the facts", context=s[:160])) | |
| return bad | |
| def _direction(text, F): | |
| """inside/outside a region of plasticity: the direction words and which number goes with which side, as COMPARISON""" | |
| c = F.comp | |
| if not c: return [] | |
| rset = lambda *xs: set().union(*(roundings(Decimal(x.rstrip("%"))) for x in xs)) | |
| IN, OUT = rset(c["ei"], c["ai"]), rset(c["eo"], c["ao"]) | |
| bad = [] | |
| for para in sentences(text): | |
| for s in para: | |
| low = s.lower() | |
| if not re.search(r"inside|region of plasticity|plastic", low) or not re.search(r"outside|the other|against|than", low): continue | |
| if re.search(r"hesitat\w*\s+less|less hesita|hesitates? (?:more|less) outside", low) and c["h"] == "hesitates more": | |
| bad.append(dict(kind="direction", text="hesitation", why="inside a region of plasticity the model hesitates more", context=s[:160])) | |
| if re.search(r"names? more genes|names more\b", low) and c["n"] == "names fewer genes": | |
| bad.append(dict(kind="direction", text="naming", why="inside a region of plasticity the model names fewer genes", context=s[:160])) | |
| opens = re.match(r"\s*(?:and\s+)?(inside|in|outside) (?:a|any) regions? of plasticity\b", low) # "A against B": A is this side | |
| if opens and not re.search(r"\bthere\b|\bthan inside\b", low): | |
| x, y = (OUT, IN) if opens.group(1) == "outside" else (IN, OUT) | |
| for a, b in re.findall(r"(\d+(?:\.\d+)?)%?\s*(?:bits\s+)?against\s+(\d+(?:\.\d+)?)", s): | |
| A, B = Decimal(a), Decimal(b) | |
| if A in y and B in x and not (A in x and B in y): | |
| bad.append(dict(kind="direction", text=f"{a} against {b}", why="the two sides are swapped", context=s[:160])) | |
| fi = low.find("inside") if "inside" in low else low.find("in a region of plasticity") | |
| fi = fi if fi >= 0 else 10 ** 6 | |
| in_first = fi < low.find("outside") if "outside" in low else True | |
| if "against" not in low and "outside" in low and "inside" in low and in_first: | |
| seg_in = s[low.find("inside"): low.find("outside")]; seg_out = s[low.find("outside"):] | |
| for mm in re.finditer(r"(?<![\w.,])(\d+(?:\.\d+)?)(?=\s*(?:%|bits?\b))", seg_in): | |
| if Decimal(mm.group(1)) in OUT and Decimal(mm.group(1)) not in IN: | |
| bad.append(dict(kind="direction", text=mm.group(0), why="the value outside a region of plasticity", context=s[:160])) | |
| for mm in re.finditer(r"(?<![\w.,])(\d+(?:\.\d+)?)(?=\s*(?:%|bits?\b))", seg_out): | |
| if Decimal(mm.group(1)) in IN and Decimal(mm.group(1)) not in OUT: | |
| bad.append(dict(kind="direction", text=mm.group(0), why="the value inside a region of plasticity", context=s[:160])) | |
| if c["h"] == "hesitates more" and re.search(r"(?:does not|doesn't|do not|never) follow|ignores? the regions", text, re.I): | |
| bad.append(dict(kind="direction", text="does not follow", why="inside a region of plasticity the model hesitates more")) | |
| if c["h"] != "hesitates more" and re.search(r"hesitation follows|follows (?:the regions of )?plasticity", text, re.I): | |
| bad.append(dict(kind="direction", text="follows plasticity", why=f"the model {c['h']} inside a region of plasticity")) | |
| return bad | |
| STOP = set("""the a an and or of in on to for from by with as at into its their that which this these those genes gene | |
| pathway pathways operon operons island islands system systems cluster locus loci branch path parallel stretch | |
| metabolism catabolism biosynthesis synthesis assembly uptake transport transporter transporters utilization utilisation | |
| degradation production maturation formation regulation response repair processing export import building builds build | |
| carried instead main one core other different differ between types type another second first sugar sugars family | |
| protein proteins breakdown handling use work working genes' related""".split()) | |
| GENERIC_OK = set("""containing like domain chain core antigen reduction oxidation respiration respiratory translation transcription | |
| decay repair recombination envelope membrane cell division replication ribosome ribosomal secretion efflux multidrug fimbriae | |
| fimbrial flagellar flagella motility chemotaxis lipid lipids fatty acid acids amino nucleotide energy stress iron sulfur | |
| phosphate nitrogen carbon osmotic heat shock toxin antitoxin phage prophage mobile element elements insertion restriction | |
| modification quorum sensing biofilm capsule polysaccharide""".split()) | |
| def interp_hints(text, F): | |
| """words of [[...]] found in no product or gene name of the facts (a hint for a retry, not a failure: a substrate | |
| can be named right from gene names alone)""" | |
| V = set(re.findall(r"[a-z]{3,}", F.txt().lower())); P = {w[:7] for w in V if len(w) >= 7} | |
| out = [] | |
| for m in re.finditer(r"\[\[(.+?)\]\]", text): | |
| for w in re.findall(r"[a-z]{4,}", m.group(1).lower()): | |
| if w in STOP or w in GENERIC_OK or w in V or (len(w) >= 7 and w[:7] in P): continue | |
| out.append(dict(text=w, where=m.group(0))) | |
| return out | |
| # ---------------------------------------------------------------- the sections and the citations (one per sentence) | |
| ROLE_KIND = dict(share="pct", rgp="pct", named="pct", definition="pct", bits="bits", ppl="fam", count="int") | |
| ROLE_SAY = dict(share="a share of {unit}", rgp="a share in a region of plasticity", named="a share of genes named", | |
| bits="a hesitation in bits", ppl="a perplexity", count="a count", definition="a number of the TERMS line") | |
| _FNUM = re.compile(r"(?<![\w.,])(\d[\d,]*(?:\.\d+)?)(?:\s*(?:to|-|–)\s*(\d[\d,]*(?:\.\d+)?))?\s*(%|bits?\b|famil(?:y|ies)\b)?", re.I) | |
| _FROLE = ((re.compile(r"\bRGP\b"), "rgp"), (re.compile(r"\bnam(?:e|es|ed|ing)\b|\bmean\b", re.I), "named"), | |
| (re.compile(r"\bshare\b", re.I), "share")) | |
| _SROLE = ((re.compile(r"regions?\s+of\s+plasticity|\bplastic\w*", re.I), "rgp"), | |
| (re.compile(r"\bnam(?:e|es|ed|ing)\b", re.I), "named"), | |
| (re.compile(r"\bcarr(?:y|ies|ied|ying)\b|\bshare\b", re.I), "share"), | |
| (re.compile(r"\bperplexity\b", re.I), "ppl")) | |
| _FOLLOW = ((re.compile(r"\s*(?:of\s+(?:the\s+)?\w+\s+)?(?:are\s+|fall\s+|lie\s+|sit\s+|is\s+)?(?:in|inside|within)\s+" | |
| r"(?:a\s+|any\s+)?regions?\s+of\s+plasticity", re.I), "rgp"), | |
| (re.compile(r"\s*of\s+(?:the\s+|all\s+)?(?:genomes|chromosomes|them)\b", re.I), "share"), | |
| (re.compile(r"\s*of\s+(?:the\s+)?genes\b", re.I), "named")) | |
| _JOIN = re.compile(r"[\s(),]*(?:against|to|and|or|versus|vs\.?)?[\s(),]*") # between two numbers of one pair | |
| TAG_RE = re.compile(r"(?:(?<=\n)|(?<=\s))[\[(]?@(" + "|".join(SECTIONS) + r")\b[\])]?[:.]?[ \t]*") # a tag anywhere after a space | |
| CITE_RE = re.compile(r"\{([^{}\n]{0,160}?)\}[ \t]*([.;:!?]*)") | |
| CID_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z]?\d+(?:-\d+)?)?") | |
| def _stem(w): return w[:7] if len(w) >= 7 else w | |
| _SUF = sorted(("ases", "ase", "ysis", "oses", "osis", "ations", "ation", "isation", "ization", "ising", "izing", "ing", | |
| "ers", "er", "ors", "or", "ions", "ion", "ive", "ic", "al", "ed", "es", "s"), key=len, reverse=True) | |
| def _norm(w): | |
| """a product word cut back to its stem: hydrolysis and glycosylhydrolase both give hydrol, secretion and secreted | |
| both secret, so a word of an interpretation is recognised in the product it comes from""" | |
| w = w.lower().strip("'-") | |
| for suf in _SUF: | |
| if w.endswith(suf) and len(w) - len(suf) >= 5: return w[:len(w) - len(suf)] | |
| return w | |
| def _same(a, b): | |
| """two words of the same thing: equal, or one's stem inside the other (never two words that merely start alike, | |
| so transport does not match transposase)""" | |
| na, nb = _norm(a), _norm(b) | |
| return na == nb or (len(na) >= 6 and na in nb) or (len(nb) >= 6 and nb in na) | |
| def _covers(w, pool): return any(_same(w, x) for x in pool) | |
| def _role_left(s, rules): | |
| """the role the last keyword before a number gives it (None: no keyword)""" | |
| best, role = -1, None | |
| for rx, r in rules: | |
| for m in rx.finditer(s): | |
| if m.start() > best: best, role = m.start(), r | |
| return role | |
| def _cite_index(F): | |
| """per facts line, by its id: the words it shows, its numbers by role (share, rgp, named, bits, ppl, count), its | |
| columns, and whether it speaks of the whole window. The role of a number is the last keyword before it (RGP, share, | |
| named, mean), else its unit, else a share: what the facts lines are built to say.""" | |
| if getattr(F, "_cidx", None): return F._cidx | |
| out = {} | |
| for lid, ln, inf in zip(F.ids, F.lines, F.info): | |
| roles = collections.defaultdict(set); prev = 0; terms = ln.startswith("TERMS"); got = [] | |
| for m in _FNUM.finditer(ln): | |
| unit = (m.group(3) or "").lower(); left = ln[prev:m.start()]; prev = m.end() | |
| soft = False | |
| if unit.startswith("bit"): role = "bits" | |
| elif unit.startswith("famil"): role = "ppl" if re.search(r"perplexity", left, re.I) else "count" | |
| elif unit == "%": | |
| role = "definition" if terms else _role_left(left, _FROLE) | |
| soft = role is None; role = role or "share" | |
| else: role = "count"; soft = True | |
| got.append([role, [m.group(1), m.group(2)], _JOIN.fullmatch(left) is not None, "." in (m.group(1) or ""), soft]) | |
| for j, g in enumerate(got): # "78% against 94%", "0.83 against 0.21 bits": one pair, one role | |
| if g[4] and g[2] and j and got[j - 1][0] != "count": g[0] = got[j - 1][0] | |
| elif g[4] and g[3] and j + 1 < len(got) and got[j + 1][2] and got[j + 1][0] != "count": g[0] = got[j + 1][0] | |
| spans = collections.defaultdict(list); colline = bool(re.fullmatch(r"S\d+\.c\d+", lid)) | |
| for role, gs, _j, _d, _s in got: | |
| for g in gs: | |
| if not g: continue | |
| try: roles[role].add(Decimal(g.replace(",", ""))) | |
| except InvalidOperation: pass | |
| if role in ("share", "rgp", "named") and not (colline and spans["share"]): | |
| try: spans[role].append(tuple(sorted(Decimal(g.replace(",", "")) for g in (gs[0], gs[1] or gs[0])))) | |
| except InvalidOperation: pass | |
| colvals = {} # the share at each column the line lists, in order | |
| mr = re.fullmatch(r"S\d+\.c(\d+)-(\d+)", lid) | |
| if colline and spans.get("share"): colvals[int(lid.split(".c")[1])] = spans["share"][0][0] | |
| elif mr and ":" in ln: | |
| vs = [Decimal(x) for x in re.findall(r"(?<![\w.])(\d+(?:\.\d+)?)%", ln.split(":", 1)[1])] | |
| a_, b_ = int(mr.group(1)), int(mr.group(2)) | |
| if len(vs) == b_ - a_ + 1: colvals = {a_ + j: v for j, v in enumerate(vs)} | |
| elif lid == "E" and inf["cols"] and spans.get("share"): colvals[min(inf["cols"])] = spans["share"][0][0] | |
| hesv = [(int(a), Decimal(b)) for a, b in re.findall(r"most hesitant at column (\d+)(?: \([^)]*\))?,(?: named [\d.]+%,)? (\d+\.\d+) bits", ln)] | |
| hesv += [(int(b), Decimal(a)) for a, b in re.findall(r"hesitates more than (\d+\.\d+) bits \(column (\d+)", ln)] | |
| if lid == "H": hesv = [(int(a), Decimal(b)) for a, b in re.findall(r"column (\d+) \([^)]*\) (\d+\.\d+) bits", ln)[:1]] | |
| lowv = [(int(a), Decimal(b)) for a, b in re.findall(r"fewest named(?: and most hesitant)? at column (\d+)(?: \([^)]*\))?, named (\d+(?:\.\d+)?)%", ln)] | |
| out[lid] = dict(id=lid, words={w.lower() for w in re.findall(r"[\w'-]+", ln)}, low=ln.lower(), roles=roles, spans=spans, colvals=colvals, | |
| hesv=hesv, lowv=lowv, | |
| cols=set(inf["cols"]) | cols_of(ln), glob=inf["glob"], head=inf.get("head"), subs=[], | |
| fams=inf.get("fams") or [], show={w.lower() for w in (inf.get("show") or ())}) | |
| for lid in list(out): # a stretch's own sub-lines: [S4] -> [S4.c34], [S4.c53-79], [S4.b45] | |
| p = lid.split(".")[0] | |
| if p != lid and p in out: out[p]["subs"].append(lid) | |
| F._cidx = out; return out | |
| def parse(text): | |
| """the model's answer as its sections: [dict(tag, sents=[dict(text, punct, cites, bold)])], each sentence what ends | |
| at a citation in braces -> (sections, dict(tail, untagged, dup, order)): what stands outside a section or after the | |
| last citation of one is a fragment the reading cannot use""" | |
| secs = []; notes = dict(tail=[], untagged="", dup=[], tags=[]) | |
| parts = TAG_RE.split("\n" + text.strip()) | |
| if parts[0].strip(): notes["untagged"] = " ".join(parts[0].split())[:200] | |
| for tag, body in zip(parts[1::2], parts[2::2]): | |
| body = " ".join(body.split()) | |
| notes["tags"].append(tag) | |
| if any(s["tag"] == tag for s in secs): notes["dup"].append(tag); continue | |
| sents = []; pos = 0 | |
| for m in CITE_RE.finditer(body): | |
| t = body[pos: m.start()].strip(); pos = m.end() | |
| ids = CID_RE.findall(m.group(1)) | |
| if not t: continue | |
| p = m.group(2) or "" | |
| if t[-1] in ".;:!?": p = "" | |
| elif not p: p = "." | |
| sents.append(dict(text=t, punct=p, cites=ids, bold=bool(re.search(r"\*\*.+?\*\*", t)))) | |
| rest = body[pos:].strip() | |
| if len(rest) > 3: notes["tail"].append(rest[:160]) | |
| if sents: secs.append(dict(tag=tag, sents=sents)) | |
| notes["order"] = [s["tag"] for s in secs] | |
| return secs, notes | |
| def _sent_bad(s, ids, F, X, extra=()): | |
| """what a sentence says that the facts lines it cites do not carry -> (bad, numbers, names): its numbers by role, | |
| its identifiers, its gene names, its columns, the words inside [[ ]], and the claims the facts make elsewhere. | |
| A stretch line carries its own sub-lines for gene names, products and columns, never for a number: a number belongs | |
| to the line that states it, and a stretch's own line states its ranges. extra: the other | |
| sentences' ids, for the sentence that opens a paragraph — an opener may summarise what its paragraph states.""" | |
| bad = []; lines = [] | |
| for i in ids: | |
| got = _resolve_id(i, X) | |
| if not got: bad.append(dict(kind="citation", text=i, why="no facts line has this id", context=s[:120])) | |
| lines += [L for L in got if L not in lines] | |
| if len(ids) > CITES_MAX: # a sentence citing everything is a sentence citing nothing | |
| bad.append(dict(kind="citation", text=", ".join(ids), why=f"a sentence cites at most {CITES_MAX} lines", context=s[:120])) | |
| if not lines: | |
| return bad + [dict(kind="citation", text=s[:60], why="the sentence cites no facts line", context=s[:120])], 0, 0 | |
| inh = [L for i in dict.fromkeys(extra) for L in _resolve_id(i, X) if L not in lines] | |
| main = lines + inh | |
| subs = [X[j] for L in main for j in L["subs"] if X[j] not in main] | |
| roles = collections.defaultdict(set); wlow = set(); cols = set(); low = [] | |
| for L in main: | |
| for k, v in L["roles"].items(): roles[k] |= v | |
| hd = X.get(L["id"].split(".")[0]) # a stretch of one column: its line and its column's | |
| if hd is not None and hd is not L and hd["cols"] == L["cols"]: # line speak of the same column | |
| for k, v in hd["roles"].items(): roles[k] |= v | |
| vis = set() | |
| for L in main + subs: | |
| wlow |= L["words"] | L["show"]; low.append(L["low"]) | |
| if L["id"] not in ("Q", "X"): cols |= L["cols"] | |
| vis |= set().union(set(), *(f["words"] for f in L["fams"])) | |
| byhead = collections.defaultdict(set) # two column lines of one stretch cited: the columns | |
| for L in main: # between them are that stretch's too | |
| if "." in L["id"] and L["cols"]: byhead[L["id"].split(".")[0]] |= L["cols"] | |
| for h, cs in byhead.items(): | |
| if h in X and len(cs) >= 1 and sum(1 for L in main if L["id"].startswith(h + ".")) >= 2: | |
| cols |= {c for c in X[h]["cols"] if min(cs) <= c <= max(cs)} | |
| heads = [X[h] for h in {L["id"].split(".")[0] for L in main} if h in X] | |
| vis |= wlow | |
| low = " ".join(low) | |
| cited = ", ".join(L["id"] for L in lines) | |
| pool = collections.defaultdict(set) | |
| for r, vs in roles.items(): pool[ROLE_KIND[r]] |= vs | |
| br = [L for L in lines if re.search(r"\.b\d+$", L["id"])] # a branch spoken of: its own families, its own shares, | |
| bm = re.search(r"\bbranch(?:es)?\b|\bparallel path\b", s, re.I) # in the clause that speaks of it (from the clause | |
| bmode = bool(br) and bool(bm) and len(lines) > len(br) # before "branch" to the end of the sentence) | |
| bstart = max(s.rfind(",", 0, bm.start()), s.rfind(";", 0, bm.start()), s.rfind(":", 0, bm.start())) + 1 if bmode else 0 | |
| if bmode and "[[" in s[bstart: bm.start()] or bmode and s[:bstart].count("[[") != s[:bstart].count("]]"): bstart = 0 | |
| inb = lambda i: bmode and i >= bstart | |
| if bmode: | |
| bwords = set().union(*(L["words"] | L["show"] for L in br)) | |
| broles = collections.defaultdict(set) | |
| for L in br: | |
| for k, v in L["roles"].items(): broles[k] |= v | |
| nnum = nname = 0 | |
| body = s | |
| for fr in sorted(F.frags, key=len, reverse=True): # "family 4" of a product: a name, not a number | |
| body = re.sub(re.escape(fr), lambda mm: re.sub(r"\d", "#", mm.group(0)), body, flags=re.I) | |
| spans = [mm.span() for mm in re.finditer(r"\[\[.*?\]\]", body, flags=re.S)] | |
| inside = lambda i: any(a <= i < b for a, b in spans) | |
| def ident(mm): | |
| tok = mm.group(0).strip("'-/.") | |
| if not re.search(r"[A-Za-z]", tok): return mm.group(0) | |
| u = re.fullmatch(r"(\d[\d,]*(?:\.\d+)?)-(bits?|fold|columns?|genes?|famil(?:y|ies)|percent)", tok) | |
| if u: return u.group(1) + " " + u.group(2) # 0.4-bit: a number and its unit | |
| chem = re.fullmatch(r"\d+(?:,\d+)*-[A-Za-z]{4,}[\w,-]*", tok) and not re.match(r"\d+(?:,\d+)*-(?:genes?|columns?|famil|fold|bits?|stretch)", tok) | |
| if tok.lower() not in low and not (inside(mm.start()) and chem): | |
| bad.append(dict(kind="identifier", text=tok, why=f"not in the lines cited ({cited})", context=s[:120])) | |
| return re.sub(r"\d", "#", mm.group(0)) | |
| body = IDENT_RE.sub(ident, body) | |
| rsp = [(m.start(1), m.end(1), m.start(2), m.end(2)) for m in # the two ends of a range | |
| re.finditer(r"(?<![\w.,])(\d[\d,]*(?:\.\d+)?)\s*(?:to|-|–)\s*(\d[\d,]*(?:\.\d+)?)(?=\s*(?:%|bits?\b|famil))", body)] | |
| def inrange(i): return any(a <= i < b or c <= i < d for a, b, c, d in rsp) | |
| prole = None; froms = []; skip_at = set(); hi_at = None | |
| scols_all = cols_of(s) | |
| cvals = {} | |
| for L in main + subs: | |
| for k2, x in L.get("colvals", {}).items(): cvals.setdefault(k2, x) | |
| colset = cols_of(s) & cols # a column the lines cited speak of ("columns 69 to 79" of | |
| for mm in NUM_RE.finditer(body): # [S4.c53-79]): a column, checked as one below | |
| if not UNIT_RE.match(body, mm.end()) and not LOWER_RE.match(body, mm.end()) and "." not in mm.group(0) \ | |
| and mm.group(0).isdigit() and int(mm.group(0)) in colset and \ | |
| re.search(r"\bcolumns?\b[\w\s,–-]{0,40}$", body[max(0, mm.start() - 48): mm.start()], re.I): | |
| continue | |
| if mm.start() in skip_at: continue # the upper end of a range verified with its lower end | |
| if inside(mm.start()): | |
| if not re.match(r"\d+(?:,\d+)*-[A-Za-z]{4,}", body[mm.start():]): | |
| bad.append(dict(kind="interpretation", text=mm.group(0), why="a number inside [[ ]]", context=s[:120])) | |
| continue | |
| raw = mm.group(0); nnum += 1 | |
| try: v = Decimal(raw.replace(",", "")) | |
| except InvalidOperation: continue | |
| u = UNIT_RE.match(body, mm.end()); unit = (u.group(1).lower() if u else ""); aend = u.end() if u else mm.end() | |
| hi = None | |
| if not unit: | |
| lo = LOWER_RE.match(body, mm.end()) | |
| if lo: unit = lo.group(2).lower(); aend = lo.end(); hi = lo.group(1); hi_at = lo.start(1) # a range's lower end: its unit | |
| kind = "pct" if unit in ("%", "percent", "per cent") else "bits" if "bit" in unit else "fam" if unit.startswith("famil") and "." in raw else None | |
| role = "bits" if kind == "bits" else "ppl" if kind == "fam" else None | |
| if kind == "pct": | |
| after = body[aend:][:44] | |
| fol = next((r for rx, r in _FOLLOW if rx.match(after)), None) # "N% in a region of plasticity" decides; "of chromosomes" | |
| left = re.split(r"[.!?]\s|\*\*", body[:mm.start()])[-1][-90:] # only over a keyword of another clause, or a distant one | |
| role = fol if fol == "rgp" else (_role_left(left[-22:], _SROLE) or fol or _role_left(left, _SROLE) or prole) | |
| prole = role or prole | |
| if inb(mm.start()) and role == "share": vs = broles.get("share", set()) # a branch's share is the branch's, not its columns' | |
| else: vs = roles.get(role, set()) if (role and ROLE_KIND.get(role) == kind) else (pool.get(kind, set()) if kind else set().union(*pool.values()) if pool else set()) | |
| ok = v in _allowed({kind or "int": vs})[kind or "int"] | |
| rrole = role or ("share" if kind == "pct" else None) # a bare percent: a share, as the facts write it | |
| if kind == "pct" and rrole == "share" and not inb(mm.start()) and not re.search(r"\bfrom\s+$", body[max(0, mm.start() - 8): mm.start()], re.I): | |
| ccs = _clause_cols(s, mm.start(), F) # the shares at the columns named, from the column lines of | |
| if ccs and all(k2 in cvals for k2 in ccs): # the lines cited: "columns 15 to 20 ... 73 to 79%" is exactly | |
| vals = [cvals[k2] for k2 in sorted(ccs)] # their lowest and highest, "column 14 ... 68%" its value | |
| al_ = lambda x: _allowed({"pct": {x}})["pct"] | |
| if hi is not None: | |
| try: vh_ = Decimal(hi.replace(",", "")) | |
| except InvalidOperation: vh_ = None | |
| if vh_ is not None and {min(v, vh_), max(v, vh_)} and min(v, vh_) in al_(min(vals)) and max(v, vh_) in al_(max(vals)): | |
| skip_at.add(hi_at); continue | |
| elif len(ccs) == 1 and v in al_(vals[0]): continue | |
| if ok and hi is not None and kind == "pct" and rrole in ("share", "rgp", "named"): | |
| try: vh = Decimal(hi.replace(",", "")) | |
| except InvalidOperation: vh = None | |
| src = br if inb(mm.start()) and rrole == "share" else main | |
| if vh is not None and re.search(r"\bfrom\s+$", body[max(0, mm.start() - 8): mm.start()], re.I): | |
| ends = _ends(_clause_cols(s, mm.start(), F), main + subs) # "from 90 to 48%": a direction along the columns | |
| if ends and v in _allowed({"pct": {ends[0]}})["pct"] and vh in _allowed({"pct": {ends[1]}})["pct"]: | |
| froms.append((v, vh)); continue | |
| bad.append(dict(kind="number", text=f"from {raw} to {hi}%", why=f"\"from A to B\" is the share at the first and at the last " | |
| f"column named, and the lines cited ({cited}) do not show both: cite those columns' lines, or give " | |
| f"the range as a line shows it", context=s[:120])) | |
| continue | |
| if vh is not None and not _range_ok(v, vh, rrole, src, scols_all, X): | |
| bad.append(dict(kind="number", text=f"{raw} to {hi}%", why=f"a range the lines cited ({cited}) do not give: the range a line " | |
| f"shows, or the lowest and highest of the lines cited, or the values at the first and last column named", | |
| context=s[:120])) | |
| continue | |
| if ok and kind == "pct" and rrole == "share" and hi is None and not inb(mm.start()): | |
| pre_ = body[max(0, mm.start() - 44): mm.start()] # "`xylE`, carried by 69%": that gene's share | |
| gm = list(re.finditer(r"`([^`\n]+)`", pre_)) | |
| g_ = gm[-1].group(1) if gm and not re.search(r"\d", pre_[gm[-1].end():]) else None | |
| if g_ and F.gshare.get(g_): | |
| if v not in set().union(*(_allowed({"pct": {x}})["pct"] for x in F.gshare[g_])): | |
| bad.append(dict(kind="number", text=f"{raw}%", why=f"`{g_}` is carried by {', '.join(str(x) + '%' for x in sorted(F.gshare[g_]))} " | |
| f"in the facts", context=s[:120])); continue | |
| pre_s = s[max(0, mm.start() - 44): mm.start()] | |
| pk = pre_s[max((x.end() for x in re.finditer(r"%|\bbits?\b|`", pre_s)), default=0):].lower() | |
| pf = [x for p_, x in F.pshare.items() if len(p_) >= 6 and p_[:14] in pk] | |
| if not g_ and pf: # "an IS1-like element transposase in 29%": that family's | |
| if v not in set().union(*(_allowed({"pct": {y}})["pct"] for x in pf for y in x)): | |
| bad.append(dict(kind="number", text=f"{raw}%", why="not the share of the family it follows in the facts", context=s[:120])); continue | |
| elif not g_: | |
| ccs = _clause_cols(s, mm.start(), F) # "column 10 ... carried by 64%": that column's share | |
| if len(ccs) == 1 and min(ccs) in cvals and not re.search(r"\bgenomes\b|\bchromosomes\b\s*$", pre_[-3:]) \ | |
| and v not in _allowed({"pct": {cvals[min(ccs)]}})["pct"] and re.search(r"carri|share|\bin\s*$|\bat\s*$", pre_[-24:]): | |
| bad.append(dict(kind="number", text=f"{raw}%", why=f"column {min(ccs)} is carried by {cvals[min(ccs)]}% in the lines cited " | |
| f"({cited})", context=s[:120])); continue | |
| if ok and kind == "pct" and role == "named" and F.gtop is not None and any(L["id"] == "G" for L in lines) \ | |
| and re.search(r"names?\s+$", body[max(0, mm.start() - 12): mm.start()]) and re.match(r"%\s+of\s+(?:the\s+)?genes\b", body[mm.end():]) \ | |
| and not re.search(r"persistent|shell|cloud|inside|outside|spine|fork|variable", re.split(r"[.;]", s[:mm.start()])[-1], re.I): | |
| if v not in _allowed({"pct": {F.gtop}})["pct"]: # "across the window it names 89% of genes": [G]'s own | |
| bad.append(dict(kind="number", text=f"{raw}%", why=f"across the window the model names {F.gtop}% of genes [G]", context=s[:120])); continue | |
| if ok and kind == "bits" and F.gbits is not None and any(L["id"] == "G" for L in lines) and not any(L["id"] in ("H", "M") for L in lines) \ | |
| and re.match(r"\s*bits?\s+on\s+average", body[mm.end():]) and v not in _allowed({"bits": {F.gbits}})["bits"]: | |
| bad.append(dict(kind="number", text=f"{raw} bits", why=f"across the window it hesitates {F.gbits} bits on average [G]", context=s[:120])); continue | |
| if ok and kind == "bits" and "." not in raw and v not in roles.get("bits", set()): | |
| bad.append(dict(kind="format", text=f"{raw} bits", why="bits rounded to a whole number: write them as the facts do", context=s[:120])); continue | |
| if not ok: | |
| say = ROLE_SAY.get(role, "a number").format(unit=F.unit) if role else "a number" | |
| bad.append(dict(kind="number", text=raw + ("%" if kind == "pct" else f" {unit}" if unit else ""), | |
| why=f"not {say} of the lines cited ({cited})", context=s[:120])) | |
| for mm in re.finditer(r"(?<![\w.-])\.\d+\b|(?<![\w,-])\d+,\d{1,2}\b(?!,?\d)", re.sub(r"\[\[.*?\]\]", " ", s)): | |
| bad.append(dict(kind="format", text=mm.group(0), why="write numbers as the facts do (0.97, not .97 or 0,97)")) | |
| for rx, down in ((TREND_DOWN, True), (TREND_UP, False)): # "fewer and fewer": shown by a "from A to B" that falls | |
| mt = rx.search(re.sub(r"\[\[.*?\]\]", " ", s)) | |
| if mt and not any((a_ > b_) if down else (a_ < b_) for a_, b_ in froms): | |
| bad.append(dict(kind="claim", text=mt.group(0), why="a trend along the columns needs its values: \"from A to B%\" at the first " | |
| "and last column named, from the lines cited", context=s[:120])) | |
| allv = set().union(*pool.values()) if pool else set() | |
| for mm in NUMW_RE.finditer(s): | |
| nnum += 1 | |
| if Decimal(NUMW[mm.group(1).lower()]) not in allv: | |
| bad.append(dict(kind="number word", text=mm.group(0), why="write numbers in digits, from the lines cited")) | |
| for mm in VAGUE_RE.finditer(s): bad.append(dict(kind="number word", text=mm.group(0), why="write numbers in digits, from the lines cited")) | |
| for mm in META_RE.finditer(s): bad.append(dict(kind="wording", text=mm.group(0), why="the reading speaks of the window, not of the facts")) | |
| whyg = (f"a branch is spoken of with its own families only ({', '.join(L['id'] for L in br)})" if bmode | |
| else f"not in the lines cited ({cited})") | |
| def known(g, at=0): | |
| gl = g.lower(); b = inb(at) | |
| pool_ = bwords if b else wlow | |
| if gl in pool_: return True | |
| if len(gl) == 3 and any(w.startswith(gl) for w in pool_ if w in {x.lower() for x in F.genes}): return True | |
| if b: return False | |
| return gl in low or any(k.endswith("…") and gl.startswith(k[:-1]) for k in F.atp) | |
| whyn = f"not in the lines cited ({cited})" | |
| for mm in re.finditer(r"`([^`\n]+)`", s): | |
| g = mm.group(1).strip(); nname += 1 | |
| if not known(g, mm.start()): bad.append(dict(kind="gene", text=g, why=whyg if inb(mm.start()) else whyn, context=s[:120])) | |
| nog = re.sub(r"`[^`\n]*`", " ", s) | |
| for mm in GENE_RE.finditer(nog): | |
| g = mm.group(1); nname += 1 | |
| if not known(g, mm.start()) and g not in ALLOWED_WORDS: bad.append(dict(kind="gene", text=g, why=whyg if inb(mm.start()) else whyn, context=s[:120])) | |
| for mm in PROT_RE.finditer(nog): | |
| g = mm.group(1) | |
| if not known(g, mm.start()) and not known(g[0].lower() + g[1:], mm.start()) and g not in ALLOWED_WORDS: | |
| nname += 1; bad.append(dict(kind="gene", text=g, why=whyg if inb(mm.start()) else whyn, context=s[:120])) | |
| for mm in OPERON_RE.finditer(re.sub(r"\[\[.*?\]\]", " ", nog)): | |
| g = mm.group(1) | |
| if not known(g, mm.start()) and g.lower() not in WORDS3: | |
| bad.append(dict(kind="gene", text=g, why=f"not an operon of the lines cited ({cited})", context=s[:120])) | |
| for mm in OPERON_RE2.finditer(s): # "the `waa` locus" over a stretch: most of its families | |
| pre = mm.group(1).lower() # must carry that name, or the label covers what it is not | |
| oc = _clause_cols(s[:mm.start()] + s[mm.end():], mm.start(), None) # the columns named nearest, not the operon's own genes | |
| fs = [f for L in main + subs if not L["glob"] for f in L["fams"] | |
| if not f["alt"] and (not oc or f["col"] is None or f["col"] in oc)] | |
| pres = {pre} | set(ALIAS.get(pre, ())) # rfa is the old name of waa | |
| nin = sum(f["label"].lower().startswith(tuple(pres)) for f in fs) | |
| if len(fs) >= 4 and nin * 2 < len(fs): | |
| bad.append(dict(kind="claim", text=mm.group(0), why=f"only {nin} of the {len(fs)} families there carry that name: name " | |
| f"the columns it is true of", context=s[:120])) | |
| miss = sorted(cols_of(s) - cols) | |
| if miss: bad.append(dict(kind="column", text="column " + ", ".join(map(str, miss[:6])), | |
| why=f"the lines cited ({cited}) do not speak of that column", context=s[:120])) | |
| scols = cols_of(s) | |
| for mm in re.finditer(r"\[\[(.+?)\]\]", s): | |
| nw = len(re.findall(r"[A-Za-z0-9][\w'-]*", mm.group(1))) | |
| if nw < 2 or nw > 6 or re.search(r"[,;]", mm.group(1)): | |
| bad.append(dict(kind="interpretation", text=mm.group(1)[:40], why="an interpretation is one phrase of 2 to 6 words, not a word " | |
| "or a list", context=mm.group(0)[:80])) | |
| cw = [w for w in re.findall(r"[A-Za-z][\w'-]{3,}|\b[A-Z][A-Za-z0-9]{1,2}\b", mm.group(1)) # GMP, DNA, Fe: words too | |
| if w.lower() not in STOP and w.lower() not in ("the", "and", "for", "its")] | |
| for w in cw: # every word of the label: in the lines cited | |
| if _covers(w.lower(), vis): continue | |
| bad.append(dict(kind="interpretation", text=w, why=f"not a word of the products or gene names of the lines cited ({cited})", | |
| context=mm.group(0)[:80])) | |
| if not cw: # "[[sugar transport]]": nothing the products must say | |
| bad.append(dict(kind="interpretation", text=mm.group(1)[:40], why="it names nothing the products of the lines cited say: build " | |
| "it from their words", context=mm.group(0)[:80])); continue | |
| fams, seen_f = [], set(); scols = _clause_cols(s, mm.start(), F) # the families the label is about: those of the | |
| b_ = (inb(mm.start()) or bstart == 0) if bmode else bool(br) and len(br) == len(lines) # stretch, column or branch | |
| nb_ = [x for x in main + subs if not x["glob"] and not (bmode and x in br)] # lines cited, narrowed to the columns named | |
| for L in (br if b_ else nb_): | |
| for f in L["fams"]: | |
| k = (f["label"], f["col"]) | |
| if k in seen_f or (f["alt"] and not b_) or (scols and f["col"] is not None and f["col"] not in scols and not b_): continue | |
| seen_f.add(k); fams.append(f) | |
| if not fams: | |
| for g in genes_of(s, F): | |
| for L in X.values(): | |
| for f in L["fams"]: | |
| if f["label"] == g and (f["label"], f["col"]) not in seen_f: | |
| seen_f.add((f["label"], f["col"])); fams.append(f) | |
| if not fams: | |
| bad.append(dict(kind="interpretation", text=mm.group(1)[:40], why=f"the lines cited ({cited}) name no family this could be about", | |
| context=mm.group(0)[:80])); continue | |
| hit = [f for f in fams if any(_covers(w.lower(), f["words"]) for w in cw)] | |
| pre = {f["label"][:3].lower() for f in fams if re.fullmatch(r"[a-z]{3}[A-Z]\w*", f["label"] or "")} | |
| operon = len(pre) == 1 and all(re.fullmatch(r"[a-z]{3}[A-Z]\w*", f["label"] or "") for f in fams) | |
| fits = len(hit) >= 2 and len(hit) * 3 >= len(fams) if operon else len(hit) * 2 >= len(fams) | |
| if len(fams) >= 3 and not fits: # a label must fit most of the families it covers; in one | |
| bad.append(dict(kind="interpretation", text=mm.group(1)[:40], | |
| why=f"this says what {len(hit)} of the {len(fams)} families of the lines cited ({cited}) do: name the columns " | |
| f"it is true of, or leave the stretch unnamed", context=mm.group(0)[:80])) | |
| else: | |
| parts_ = [[w for w in re.findall(r"[A-Za-z][\w'-]{3,}", x) if w.lower() not in STOP and w.lower() not in GENERIC | |
| and w.lower()[:-1] not in GENERIC] | |
| for x in re.split(r"\s+and\s+|,\s*|\s*/\s*", mm.group(1))] | |
| for cp in [x for x in parts_ if len(x) >= 2] if len(fams) >= 3 else []: | |
| if not any(sum(_covers(w.lower(), f["words"]) for w in cp) >= 2 for f in hit): | |
| bad.append(dict(kind="interpretation", text=" ".join(cp)[:40], | |
| why="its words come one from each family: no family of the lines cited does all of it", | |
| context=mm.group(0)[:80])); break | |
| for rx, what, key, unit_ in ((HES_RE, "most hesitant", "hesv", r"(\d+(?:\.\d+)?)\s*bits?\b"), (LOW_RE, "fewest named", "lowv", r"(\d+(?:\.\d+)?)\s*%")): | |
| for mm in rx.finditer(s): | |
| if what == "most hesitant" and re.search(r"\bno column\b|hardly|barely|seldom", s, re.I): continue | |
| st_ = [t for L in main for t in L[key]] # what the lines cited state, column and value | |
| if not st_: | |
| bad.append(dict(kind="claim", text=mm.group(0), why=f"the lines cited ({cited}) do not state a column as the {what}", context=s[:120])); continue | |
| cc = _claim_cols(s, mm, F) or cols_of(s) | |
| if cc and not (cc & {k2 for k2, _ in st_}): | |
| bad.append(dict(kind="claim", text=mm.group(0), why=f"the lines cited ({cited}) state column {', '.join(str(k2) for k2, _ in st_[:3])} " | |
| f"as the {what}", context=s[:120])); continue | |
| seg = re.split(r"[;]|\.\s", s[mm.end():])[0] | |
| vm = re.search(unit_, seg) | |
| if not vm: | |
| bef = list(re.finditer(unit_, s[:mm.start()])); vm = bef[-1] if bef else None | |
| if vm: # its value: the one stated with that column | |
| vv = Decimal(vm.group(1)); okv = [x for k2, x in st_ if (not cc or k2 in cc)] | |
| if okv and not any(vv in _allowed({"bits" if key == "hesv" else "pct": {x}})["bits" if key == "hesv" else "pct"] for x in okv): | |
| bad.append(dict(kind="number", text=vm.group(0), why=f"the {what} there is {okv[0]}{' bits' if key == 'hesv' else '%'} in the lines " | |
| f"cited ({cited})", context=s[:120])) | |
| for mm in PLASTIC_RE.finditer(s): | |
| ab = F.plastic.get("most" if mm.group(1).lower() == "most" else "least") | |
| if not ab or not any(L["id"] == "P" for L in lines): | |
| bad.append(dict(kind="claim", text=mm.group(0), why="only the PLASTICITY line [P] names a stretch so", context=s[:120])); continue | |
| cc = cols_of(s) | |
| if cc and not (cc & set(range(ab[0], ab[1] + 1))): | |
| bad.append(dict(kind="claim", text=mm.group(0), why=f"the facts say so of columns {ab[0]} to {ab[1]} only", context=s[:120])) | |
| plain = re.sub(r"\[\[.*?\]\]", " ", s) | |
| K, ready, opens = F.kind, READY_RE.search(plain), OPENS_RE.search(plain) | |
| hesit = re.search(r"\bhesitat", plain, re.I) | |
| mcited = any(L["id"] == "M" for L in main) | |
| if mcited and K: bad += _m_direction(plain, K, s) # the two kinds of column compared, as [M] states it | |
| nbh = [X[f"S{int(h['id'][1:]) + d}"] for h in heads if re.fullmatch(r"S\d+", h["id"]) for d in (-1, 1) | |
| if f"S{int(h['id'][1:]) + d}" in X] # "breaks the spine": the stretches on either side | |
| for kw in ("spine", "fork", "variable", "branch"): # a kind of column the sentence speaks of: in its lines | |
| if re.search(rf"\b{kw}(?:e?s)?\b", plain, re.I) and not any(kw in L["low"] for L in main + subs + heads | |
| + (nbh if kw != "branch" else [])): | |
| bad.append(dict(kind="claim", text=kw, why=f"no line cited ({cited}) has a {kw} here", context=s[:120])) | |
| for mm in re.finditer(r"(?<!\d )\b(?:all (persistent|shell|cloud)|(persistent|shell|cloud) famil\w+)\b", plain, re.I): | |
| pw = PART[(mm.group(1) or mm.group(2)).lower()] # "all persistent", "persistent families": every family | |
| cc = _clause_cols(s, mm.start(), F) # of the columns named (a branch: of the branch) | |
| src_ = br if inb(mm.start()) or (br and len(br) == len(lines)) else [L for L in main + subs if not L["glob"] and L not in br] | |
| fam_ = [f for L in src_ for f in L["fams"] if f.get("part") and (L in br or not f["alt"]) | |
| and (L in br or not cc or f["col"] is None or f["col"] in cc)] | |
| other = [f for f in fam_ if f["part"] != pw] | |
| if fam_ and other: | |
| bad.append(dict(kind="claim", text=mm.group(0), why=f"{len(other)} of the {len(fam_)} families of the lines cited there are " | |
| f"not {mm.group(1) or mm.group(2)}", context=s[:120])) | |
| for mm in re.finditer(r"(?<!\d )(?<!all )\b(persistent|shell|cloud)\b(?! famil)(?! and)", plain, re.I): # "`selB`, persistent" | |
| cc = _clause_cols(s, mm.start(), F) # of one column: that column's family | |
| if len(cc) != 1 or inb(mm.start()): continue | |
| fam_ = [f for L in main + subs if not L["glob"] and L not in br for f in L["fams"] if not f["alt"] and f.get("part") and f["col"] in cc] | |
| if fam_ and fam_[0]["part"] != PART[mm.group(1).lower()]: | |
| bad.append(dict(kind="claim", text=mm.group(0), why=f"the family at column {min(cc)} is {PARTN[fam_[0]['part']]}", context=s[:120])) | |
| kinds = getattr(F, "kinds", {}) # "variable columns 63 to 65", "column 2 is a spine" | |
| for mm in list(re.finditer(r"\b(spine|fork|variable) columns? (\d+)(?:\s*(?:to|-)\s*(\d+))?(?!\s*%)", plain, re.I)) + \ | |
| list(re.finditer(r"\bcolumns? (\d+)(?:\s*(?:to|-)\s*(\d+))?,? (?:is|are) (?:a |an |all )?(spine|fork|variable)\b(?! and)", plain, re.I)): | |
| g = mm.groups() | |
| kw, a_, b_ = (g[0], g[1], g[2]) if not g[0].isdigit() else (g[2], g[0], g[1]) | |
| ks = range(int(a_), int(b_ or a_) + 1) | |
| wrong = [k for k in ks if k in kinds and kinds[k] != kw.lower()] | |
| if wrong: bad.append(dict(kind="claim", text=mm.group(0), why=f"column {wrong[0]} is a {kinds[wrong[0]]} column", context=s[:120])) | |
| for mm in re.finditer(r"\b(\d+) (persistent|shell|cloud) famil\w+", plain, re.I): # "a branch of 9 shell families": 9 of them | |
| pw = PART[mm.group(2).lower()]; cc = _clause_cols(s, mm.start(), F) | |
| src_ = br if inb(mm.start()) or (br and len(br) == len(lines)) else [L for L in main + subs if not L["glob"] and L not in br] | |
| fam_ = [f for L in src_ for f in L["fams"] if f.get("part") and (L in br or not f["alt"]) | |
| and (L in br or not cc or f["col"] is None or f["col"] in cc)] | |
| if fam_ and sum(f["part"] == pw for f in fam_) < int(mm.group(1)): | |
| bad.append(dict(kind="claim", text=mm.group(0), why=f"{sum(f['part'] == pw for f in fam_)} of the {len(fam_)} families of the " | |
| f"lines cited there are {mm.group(2).lower()}", context=s[:120])) | |
| if ready or (hesit and opens and not mcited): # "reads the spines readily, hesitates where it opens" | |
| if not any(L["id"] in ("M", "H", "C", "G") for L in main): | |
| bad.append(dict(kind="claim", text=(ready or opens).group(0), why="where the model reads well and where it hesitates is " | |
| "stated by [M] (by kind of column), [H], [C] or [G]: cite the line", context=s[:120])) | |
| if ready and re.search(r"\bspines?\b", plain, re.I) and K and K["named_spine"] < K["named_other"] - 0.005: | |
| bad.append(dict(kind="claim", text=ready.group(0), why="the model names fewer genes on the spine columns of this window " | |
| "than on the fork and variable ones [M]", context=s[:120])) | |
| if ready and re.search(r"\b(?:fork|variable)\b", plain, re.I) and not re.search(r"\bspines?\b", plain, re.I) \ | |
| and K and K["named_other"] < K["named_spine"] - 0.005: | |
| bad.append(dict(kind="claim", text=ready.group(0), why="the model names fewer genes on the fork and variable columns of this " | |
| "window than on the spine ones [M]", context=s[:120])) | |
| if hesit and opens and not _claim_cols(s, opens, F) and not scols: | |
| if K and K["bits_other"] <= K["bits_spine"] + 0.02: | |
| bad.append(dict(kind="claim", text=opens.group(0), why="the fork and variable columns of this window hesitate no more " | |
| "than the spine columns [M]", context=s[:120])) | |
| elif F.hes_spine: | |
| bad.append(dict(kind="claim", text=opens.group(0), why="the most hesitant column of this window is a spine column [M], " | |
| "[H]: say where it hesitates most, or name the columns", context=s[:120])) | |
| for mm in re.finditer(r"[A-Za-z][A-Za-z']{4,}", re.sub(r"\[\[.*?\]\]|`[^`]*`|\"[^\"]*\"", " ", s)): | |
| w = mm.group(0).lower() # biology belongs inside [[ ]]: a product's word outside it | |
| if w in PROSE or w in STOP or w in F.pnames or w not in F.pwords: continue | |
| bad.append(dict(kind="interpretation", text=mm.group(0), why="a word of the products outside [[ ]]: what genes do is written " | |
| "inside the brackets", context=s[:120])) | |
| for mm in NEG_RE.finditer(plain): bad.append(dict(kind="claim", text=mm.group(0), why="the facts never say chromosomes lack a gene")) | |
| for mm in SUP_RE.finditer(plain): bad.append(dict(kind="claim", text=mm.group(0), why="a superlative the facts do not state")) | |
| bad += _only_majority(plain) | |
| for mm in LEAST_RE.finditer(s): bad.append(dict(kind="claim", text=mm.group(0), why="the facts give the most hesitant columns, not the least")) | |
| for mm in re.finditer(r"outside (?:any|a) regions? of plasticity,? in (?:only )?\d[\d.]*(?: to \d[\d.]*)?%", s, re.I): | |
| bad.append(dict(kind="claim", text=mm.group(0), why="the facts give the share of calls inside a region of plasticity")) | |
| for mm in re.finditer(r"\b\d+\s+(?:in|out of|of every)\s+\d+\b(?!\s*%|\.\d|\s+to\s+\d)", plain): | |
| bad.append(dict(kind="claim", text=mm.group(0), why="a fraction the facts do not give")) | |
| for mm in re.finditer(r"\*\*(.+?)\*\*", s, re.S): # the bold opening: structure, no biology | |
| b = next((x for x in BIO_RE.finditer(re.sub(r"\[\[.*?\]\]|`[^`]*`|\"[^\"]*\"", " ", mm.group(1))) if x.group(0).lower() not in low), None) | |
| if b: bad.append(dict(kind="claim", text=b.group(0), why="biology outside [[ ]], in a bold sentence", context=mm.group(1)[:120])) | |
| if F.comp and any(L["id"] == "C" for L in lines) and not re.search(r"\bspines?\b", plain, re.I): | |
| c = F.comp; low_ = plain.lower() # "0.83 against 0.21 bits", "78% against 94%": [C]'s pairs, | |
| inside_first = low_.find("inside") < low_.find("outside") if "outside" in low_ else True # the side named first first | |
| for mm in re.finditer(r"(\d+(?:\.\d+)?)(%|\s*bits)?\s+against\s+(\d+(?:\.\d+)?)\s*(%|bits?)", plain): | |
| pc = mm.group(4).startswith("%") or (mm.group(2) or "").strip() == "%" | |
| want = (c["ai"], c["ao"]) if pc else (c["ei"], c["eo"]) | |
| if not inside_first: want = want[::-1] | |
| A, B = Decimal(mm.group(1)), Decimal(mm.group(3)) | |
| k_ = "pct" if pc else "bits" | |
| if A not in _allowed({k_: {Decimal(want[0].rstrip("%"))}})[k_] or B not in _allowed({k_: {Decimal(want[1].rstrip("%"))}})[k_]: | |
| bad.append(dict(kind="number", text=mm.group(0), why=f"[C] gives {want[0]} against {want[1]}{'' if pc else ' bits'}", context=s[:120])) | |
| if F.kind and any(L["id"] == "M" for L in lines) and re.search(r"\bspines?\b", plain, re.I): | |
| K = F.kind; low_ = plain.lower() # [M]'s pairs, the kind named first first | |
| ks = [(low_.find(w), w) for w in ("spine", "fork", "variable", "other") if w in low_] | |
| sp_first = min(ks)[1] == "spine" if ks else True | |
| for mm in re.finditer(r"(\d+(?:\.\d+)?)(%|\s*bits)?\s+against\s+(\d+(?:\.\d+)?)\s*(%|bits?)", plain): | |
| pc = mm.group(4).startswith("%") or (mm.group(2) or "").strip() == "%" | |
| want = (fmt_pct(K["named_spine"]), fmt_pct(K["named_other"])) if pc else (fmt_bits(K["bits_spine"]), fmt_bits(K["bits_other"])) | |
| if not sp_first: want = want[::-1] | |
| k_ = "pct" if pc else "bits" | |
| if Decimal(mm.group(1)) not in _allowed({k_: {Decimal(want[0].rstrip("%"))}})[k_] or \ | |
| Decimal(mm.group(3)) not in _allowed({k_: {Decimal(want[1].rstrip("%"))}})[k_]: | |
| bad.append(dict(kind="number", text=mm.group(0), why=f"[M] gives {want[0]} against {want[1]}{'' if pc else ' bits'}, the kind " | |
| f"named first first", context=s[:120])) | |
| bad += _direction(s, F) + _colgene(s, F) | |
| return bad, nnum, nname | |
| _MENTION = re.compile(r"\b(?:variable |fork |spine )?columns?,?\s+\d+(?:\s*(?:to|-|–|,|and)\s*\d+)*(?![.,]?\d|\s*%)|" | |
| r"`[^`\n]+`(?:\s+(?:to|through)\s+`[^`\n]+`)?", re.I) | |
| def _clause_cols(s, i, F=None): | |
| """the columns a label or an operon at position i of a sentence is about: in its clause ("columns 12 to 14 ... [[x]], | |
| and columns 29 to 32 ... [[y]]": each label its own), the mention nearest to it, columns ("columns 30 to 43") or genes | |
| ("`thiB` to `thiQ`", "the `cai` genes"); the whole sentence's columns when the clause names none""" | |
| m = re.sub(r"\[\[.*?\]\]", lambda x: "#" * len(x.group(0)), s) | |
| cuts = [0] + [x.end() for x in re.finditer(r",\s+and\s+|;\s+|,\s+while\s+|,\s+then\s+", m)] + [len(s) + 1] | |
| a, b = next(((a, b) for a, b in zip(cuts, cuts[1:]) if a <= i < b), (0, len(s))) | |
| best = None | |
| for mm in _MENTION.finditer(m, a, min(b, len(m))): | |
| t = s[mm.start(): mm.end()] | |
| if t.startswith("`"): | |
| if F is None: continue | |
| gs = re.findall(r"`([^`\n]+)`", t) | |
| ks = [F.at.get(g) or set().union(set(), *(F.at[x] for x in F.genes if len(g) == 3 and x.startswith(g))) for g in gs] | |
| if not all(ks): continue | |
| c = set(range(min(ks[0]), max(ks[-1]) + 1)) if len(ks) == 2 and min(ks[0]) <= max(ks[-1]) else set().union(*ks) | |
| ex = cols_of(s[a:b]) | |
| if ex and c & ex: c = c & ex | |
| else: | |
| c = cols_of(t) | |
| if not c: continue | |
| d = mm.start() - i if mm.start() >= i else i - mm.end() | |
| if best is None or d < best[0]: best = (d, c) | |
| return best[1] if best else cols_of(s[a:b]) or cols_of(s) | |
| TREND_DOWN = re.compile(r"\bfewer and fewer\b|\b(?:falling|decreasing|declining|dwindling|dropping|thinning)\b", re.I) | |
| TREND_UP = re.compile(r"\bmore and more\b|\b(?:rising|increasing|growing)\b", re.I) | |
| def _ends(cs, lines): | |
| """the shares the lines show at the first and last of the columns cs (None when one of them is not shown)""" | |
| if not cs: return None | |
| cv = {} | |
| for L in lines: | |
| for k, v in L.get("colvals", {}).items(): cv.setdefault(k, v) | |
| a, b = min(cs), max(cs) | |
| return (cv[a], cv[b]) if a in cv and b in cv and a != b else None | |
| def _range_ok(a, b, role, lines, scols, X): | |
| """a range of a sentence ("84 to 88%"): one a line cited shows; or the lowest and highest the lines cited show, in | |
| that role (several column lines); or, for a share, the values at the first and last column the sentence names""" | |
| al = lambda x: _allowed({"pct": {x}})["pct"] | |
| lo_, hi_ = min(a, b), max(a, b) | |
| sp = [t for L in lines for t in L.get("spans", {}).get(role, [])] | |
| if not sp: return True # nothing to hold it against: the value check decides | |
| if any(lo_ in al(x) and hi_ in al(y) for x, y in sp): return True | |
| covered = set().union(set(), *(L["cols"] for L in lines if L.get("spans", {}).get(role))) | |
| if lo_ in al(min(x for x, _ in sp)) and hi_ in al(max(y for _, y in sp)) and (not scols or scols <= covered): return True | |
| if role == "share" and scols: | |
| ends = [] | |
| for k in (min(scols), max(scols)): | |
| L = next((L for L in lines if re.fullmatch(r"S\d+\.c\d+", L["id"]) and L["cols"] == {k}), None) | |
| if L is None or not L["spans"].get("share"): return False | |
| ends.append(L["spans"]["share"][0][0]) | |
| if {x for e in ends for x in al(e)} >= {a, b} and a in al(ends[0]) and b in al(ends[1]): return True | |
| return False | |
| def _resolve_id(i, X): | |
| """the lines an id stands for: itself; a range the model made up ("S2.c77-79") or one column of a range line | |
| ("S2.c20" for [S2.c20-24]): the column lines of that stretch within it""" | |
| if i in X: return [X[i]] | |
| m = re.fullmatch(r"(S\d+)\.c(\d+)(?:-(\d+))?", i) | |
| if not m or m.group(1) not in X: return [] | |
| a = int(m.group(2)); b = int(m.group(3) or a) | |
| out = [X[j] for j in X[m.group(1)]["subs"] if re.match(r"S\d+\.c", j) and X[j]["cols"] and | |
| (min(X[j]["cols"]) <= b and max(X[j]["cols"]) >= a)] | |
| return out if out and set().union(*(L["cols"] for L in out)) >= set(range(a, b + 1)) & X[m.group(1)]["cols"] else [] | |
| _KIND_W = r"(spines?|spine columns?|fork|variable|others?|other ones)" | |
| def _m_direction(p, K, s): | |
| """"names more genes on the spine columns", "hesitates less there": in the direction [M] gives, for the kind of | |
| column the clause is about (said, or the first one the sentence names)""" | |
| bad = []; low = p.lower() | |
| kinds = [(low.find(w), w) for w in ("spine", "fork", "variable") if w in low] | |
| first = min(kinds)[1] if kinds else None | |
| side = lambda w: "spine" if w and w.startswith("spine") else "other" if w else None | |
| for m in re.finditer(r"\bnames?\s+(?:about\s+|nearly\s+|roughly\s+)?(more|fewer|as many)\s+(?:genes\s+)?(?:(?:on|in|at|over)\s+the\s+" | |
| + _KIND_W + r"|there)?", low): | |
| sd = side(m.group(2)) or side(first) | |
| if not sd: continue | |
| d = K["named_spine"] - K["named_other"]; d = d if sd == "spine" else -d | |
| want = "more" if d > 0.02 else "fewer" if d < -0.02 else "as many" | |
| if m.group(1) != want: | |
| bad.append(dict(kind="claim", text=m.group(0), why=f"[M]: the model names {'about as many' if want == 'as many' else want} genes " | |
| f"on the {'spine' if sd == 'spine' else 'fork and variable'} columns", context=s[:120])) | |
| for m in re.finditer(r"\bhesitat\w*\s+(?:about\s+|nearly\s+)?(more|less|as much)\b(?:\s+(?:(?:on|in|at|over)\s+the\s+" + _KIND_W | |
| + r"))?", low): | |
| sd = side(m.group(2)) or ("other" if OPENS_RE.search(low[m.end():]) else side(first)) | |
| if not sd: continue | |
| d = K["bits_spine"] - K["bits_other"]; d = d if sd == "spine" else -d | |
| want = "more" if d > 0.02 else "less" if d < -0.02 else "as much" | |
| if m.group(1) != want: | |
| bad.append(dict(kind="claim", text=m.group(0), why=f"[M]: the model hesitates {'about as much' if want == 'as much' else want} " | |
| f"on the {'spine' if sd == 'spine' else 'fork and variable'} columns", context=s[:120])) | |
| return bad | |
| def check_cited(text, F): | |
| """the model's answer against the facts, sentence by sentence, each against the lines it cites (stricter than the | |
| whole-text check: nothing may come from another line) -> dict(ok, sections, notes, bad, numbers, names, words, | |
| sentences, cited, structure)""" | |
| X = _cite_index(F) | |
| secs, notes = parse(text) | |
| nnum = nname = 0; bad = []; ns = 0 | |
| for sec in secs: | |
| rest = [i for st in sec["sents"][1:] for i in st["cites"]] # an opener may summarise its own paragraph | |
| for i, st in enumerate(sec["sents"]): | |
| st["inherit"] = [x for x in dict.fromkeys(rest) if x not in st["cites"]] if i == 0 else [] | |
| b, n1, n2 = _sent_bad(st["text"], st["cites"], F, X, st["inherit"]) | |
| st["bad"] = b; st["ok"] = not b; st["opens"] = i == 0 | |
| nnum += n1; nname += n2; ns += 1; bad += b | |
| for sec in secs: # "the spine after it", "then": further along the window | |
| prev = None | |
| for i, st in enumerate(sec["sents"]): | |
| cs = cols_of(st["text"]) or set().union(set(), *[L["cols"] for c in st["cites"] for L in _resolve_id(c, X) if not L["glob"]]) | |
| if prev and cs and i and re.search(r"\b(?:after (?:it|them|this|that)|then|next|further on|beyond (?:it|them))\b", | |
| re.sub(r"\[\[.*?\]\]", " ", st["text"]), re.I) and min(cs) < min(prev): | |
| b = dict(kind="claim", text="after it", why=f"columns {min(cs)} to {max(cs)} come before the columns of the sentence " | |
| f"before (from {min(prev)}): the window is read in its order", context=st["text"][:120]) | |
| st["bad"].append(b); st["ok"] = False; bad.append(b) | |
| prev = cs or prev | |
| order = [s["tag"] for s in secs] | |
| want = [t for t in SECTIONS if t in order] | |
| struct = [] | |
| if order != want: struct.append(f"the paragraphs are {' '.join('@' + t for t in order) or 'not tagged'}: write " | |
| + " ".join("@" + t for t in SECTIONS) + " in that order") | |
| for t in SECTIONS: | |
| if t not in order: struct.append(f"the @{t} paragraph is missing") | |
| if notes["dup"]: struct.append("these paragraphs are written twice: " + ", ".join("@" + t for t in notes["dup"])) | |
| if notes["untagged"]: struct.append(f'this stands before the first paragraph, with no tag: "{notes["untagged"][:80]}"') | |
| for t in notes["tail"]: struct.append(f'this sentence cites no facts line: "{t[:80]}"') | |
| return dict(ok=not bad and not struct, sections=secs, notes=notes, bad=bad, numbers=nnum, names=nname, | |
| words=_words(render(secs)), sentences=ns, cited=sum(len(st["cites"]) for sec in secs for st in sec["sents"]), | |
| structure=struct, _F=F, _X=X) | |
| def _lead(t): | |
| """a paragraph whose opening sentence was dropped: its first clause takes the bold, so every paragraph still opens | |
| on one (the bold is the page's form, not a claim)""" | |
| if "**" in t: return t | |
| m = re.match(r"([^,;:*]{10,70})(?=[,;:]\s)", t) | |
| if m and 3 <= len(m.group(1).split()) <= 10 and m.group(1).count("`") % 2 == 0 and "[[" not in m.group(1): | |
| return f"**{m.group(1)}**" + t[m.end():] | |
| return t | |
| def render(secs): | |
| """the sections as the reading the page shows: no tags, no citations, one paragraph each""" | |
| return "\n\n".join(_lead(" ".join(st["text"] + st["punct"] for st in sec["sents"])) for sec in secs if sec["sents"]) | |
| def _words(t): return len(re.findall(r"[A-Za-z0-9][\w'.,%-]*", t.replace("**", ""))) | |
| def _carries(t): | |
| """what a sentence tells the reader: its numbers with their unit, its gene names, the columns it names — what is | |
| lost if it goes, and what another sentence already says if it stays""" | |
| out = ({" ".join(m.group(0).split()) for m in re.finditer(r"\d[\d,]*(?:\.\d+)?\s*(?:%|bits?|famil\w+)", t)} | |
| | {g.strip() for g in re.findall(r"`([^`\n]+)`", t)} | {f"c{c}" for c in cols_of(t)}) | |
| for m in re.finditer(r"\[\[(.+?)\]\]", t): out.add("[[" + m.group(1).lower() + "]]") | |
| return out | |
| def resolve(chk, limit=WORDS_MAX): | |
| """the reading to serve: the sentences whose facts hold, then sentences dropped until the text fits the word limit | |
| — the ones that add least (no id of their own, nothing of a sub-line or a branch, no interpretation) first, and never | |
| an opening sentence whose paragraph would be left with its promise alone. An opener that leant on its paragraph's | |
| other sentences is checked again against what is left of them: the citation holds for the text served, not only for | |
| the text written -> (text or None, dict(dropped, dropped_len, unled, sections, words, why))""" | |
| F, X = chk.get("_F"), chk.get("_X") | |
| secs = []; nbad = 0 | |
| for sec in chk["sections"]: | |
| keep = [st for st in sec["sents"] if st["ok"]] | |
| nbad += len(sec["sents"]) - len(keep) | |
| if keep: secs.append(dict(sec, sents=keep, n0=len(sec["sents"]))) # nothing of the section holds: the section goes | |
| rep = dict(dropped=nbad, dropped_len=0, unled=0, sections=[], words=0, repeats=0) | |
| seen_c = set() # a sentence whose numbers, genes and columns were | |
| for sec in secs: # all said before: a repeat, left out | |
| keep = [] | |
| for st in sec["sents"]: | |
| c = _carries(st["text"]) | |
| if c and c <= seen_c and len(sec["sents"]) > 1 and not (st is sec["sents"][0] and sec["tag"] in KEEP): | |
| rep["repeats"] += 1; continue | |
| seen_c |= c; keep.append(st) | |
| sec["sents"] = keep | |
| secs = [x for x in secs if x["sents"]] | |
| def prune(): | |
| """after every drop: an opener no longer carried by its own paragraph, and a paragraph left with nothing but the | |
| promise its opener made""" | |
| nonlocal secs | |
| for sec in secs: | |
| st = sec["sents"][0] if sec["sents"] else None | |
| if not (st and st["opens"] and st.get("inherit") and F is not None and X is not None): continue | |
| left = [i for s2 in sec["sents"][1:] for i in s2["cites"]] | |
| if all(i in left for i in st["inherit"]): continue | |
| b, _n1, _n2 = _sent_bad(st["text"], st["cites"], F, X, [i for i in st["inherit"] if i in left]) | |
| if b: sec["sents"] = sec["sents"][1:]; rep["dropped"] += 1 | |
| secs = [s for s in secs if s["sents"] | |
| and not (len(s["sents"]) == 1 and s["sents"][0]["opens"] and s["n0"] > 1 | |
| and not re.search(r"\d", s["sents"][0]["text"]) and s["tag"] not in KEEP)] | |
| prune() | |
| tags = [s["tag"] for s in secs] | |
| rep.update(sections=tags, words=_words(render(secs))) | |
| if any(t not in tags for t in KEEP) or len(secs) < len(SECTIONS) - 1: | |
| rep["why"] = "too little of the reading is supported by the facts it cites"; return None, rep | |
| for _ in range(40): # the word limit: whole sentences at a time | |
| if _words(render(secs)) <= limit: break | |
| all_ = [(s, j, st) for s in secs for j, st in enumerate(s["sents"])] | |
| carries = {(id(s), j): _carries(st["text"]) for s, j, st in all_} | |
| cand = [] | |
| for s, j, st in all_: | |
| if len(s["sents"]) == 1 and s["tag"] in KEEP: continue # @start and @whole stay | |
| others = set().union(set(), *(v for k, v in carries.items() if k != (id(s), j))) | |
| uniq = len(carries[(id(s), j)] - others) # the numbers, genes and columns it alone carries | |
| sub = any("." in i for i in st["cites"]); interp = "[[" in st["text"] | |
| if j != len(s["sents"]) - 1 and (uniq or sub or interp): continue # the tail of a paragraph, or a sentence | |
| if st["opens"] and len(s["sents"]) > 1: continue # that repeats what another one carries | |
| cand.append((uniq + 2 * sub + 2 * interp, DROP_ORDER.index(s["tag"]) if s["tag"] in DROP_ORDER else 9, | |
| -_words(st["text"]), id(s), s, j)) | |
| if not cand: break | |
| *_k, s, j = min(cand, key=lambda c: c[:4]) | |
| del s["sents"][j]; rep["dropped_len"] += 1 | |
| prune() | |
| text = render(secs) | |
| rep.update(words=_words(text), sections=[s["tag"] for s in secs], unled=sum(not s["sents"][0]["opens"] for s in secs), | |
| interp=len(re.findall(r"\[\[", text)), | |
| kept=[dict(tag=s["tag"], text=st["text"], cites=st["cites"], inherit=st.get("inherit") or []) | |
| for s in secs for st in s["sents"]]) | |
| cov = set() # the columns the reading served speaks of | |
| for s in secs: | |
| for st in s["sents"]: | |
| cov |= cols_of(st["text"]) | set().union(set(), *[X[i]["cols"] for i in st["cites"] if X and i in X and not X[i]["glob"]]) | |
| rep["covered"] = len(cov) | |
| if any(t not in rep["sections"] for t in KEEP) or len(secs) < len(SECTIONS) - 1: | |
| rep["why"] = "too little of the reading is supported by the facts it cites"; return None, rep | |
| if rep["words"] < WORDS_MIN: rep["why"] = f"only {rep['words']} words of the reading are supported by the facts"; return None, rep | |
| if rep["words"] > limit: rep["why"] = f"{rep['words']} words after the sections were trimmed"; return None, rep | |
| return text, rep | |
| def retry_cited(chk, rep, truncated=False): | |
| """what to tell the model about its rejected reading: the sentences whose facts are not in their citations, what | |
| the structure asks, and the length""" | |
| say = list(chk["structure"][:4]) | |
| n = 0 | |
| for sec in chk["sections"]: | |
| for st in sec["sents"]: | |
| if st["ok"] or n >= 6: continue | |
| n += 1 | |
| why = "; ".join(dict.fromkeys(f"{b['text']}: {b['why']}" for b in st["bad"][:3])) | |
| say.append(f'in @{sec["tag"]}, "{st["text"][:90]}" {{{", ".join(st["cites"]) or "no id"}}} — {why}') | |
| if truncated: say.append(f"the text was cut at its length limit: write at most {WORDS_MAX} words") | |
| elif chk["words"] > WORDS_TARGET: say.append(f"the reading is {chk['words']} words: write {WORDS_MIN_ASK} to {WORDS_TARGET}, " | |
| "so that nothing has to be cut") | |
| if rep and rep.get("unled"): say.append("a paragraph is left without its opening sentence: write an opener that stands on the " | |
| "lines its own paragraph cites") | |
| if rep and rep.get("interp", 1) == 0 and chk.get("_F") is not None and "Q" in chk["_F"].ids: | |
| say.append("the reading names no biology: put one [[interpretation]] of 2 to 6 words on a group of [Q], from the words [Q] " | |
| "gives it, naming that group's columns and citing its stretch line and [Q]") | |
| if rep and rep.get("why"): say.append(rep["why"]) | |
| return ("Your reading was rejected: " + "; ".join(say) + ". Rewrite the whole reading, following the form, the citations " | |
| "and the rules: every number, gene name and column of a sentence standing in the facts lines that sentence cites, " | |
| "nothing from any other line.") | |
| def reason(chk, truncated, rep=None): | |
| """a short account of a failed check, for the page (its details in `bad`)""" | |
| b = chk["bad"] | |
| if truncated and not b: return "the model's text was cut at its length limit" | |
| kinds = collections.Counter(x["kind"] for x in b) | |
| what = dict(number="a number the facts lines its sentence cites do not carry", scope="a number given to the wrong genes or columns", | |
| column="a gene or a column its sentence does not cite", direction="the comparison inside and outside regions of plasticity inverted", | |
| claim="a claim this window's data do not make", gene="a gene not in the lines its sentence cites", | |
| identifier="a name not in this window", interpretation="an interpretation the products and gene names do not carry", | |
| citation="a sentence written from facts it does not cite", wording="words about the facts rather than the window", | |
| format="a number badly written") | |
| what["number word"] = "a number in words" | |
| if not kinds and chk.get("structure"): return "the model's text did not keep the reading's sections after a second attempt" | |
| if not kinds and rep and rep.get("why"): return rep["why"] | |
| return "the model's text still had " + ", ".join(what.get(k, k) for k, _ in kinds.most_common(3)) + " after a second attempt" | |
| # ---------------------------------------------------------------- the reading as HTML (the page's classes) | |
| INTERP_TITLE = "interpretation from the gene names and products, not measured here" | |
| def to_html(text, F): | |
| text = re.sub(r"^\s*#+\s*", "", text.strip(), flags=re.M) # no headings | |
| genes = sorted(F.genes, key=len, reverse=True) | |
| gre = re.compile(r"(?<![\w`])(" + "|".join(map(re.escape, genes)) + r")(?![\w])") if genes else None | |
| known = lambda g: g in F.genes or (g.isalpha() and g.islower() and len(g) == 3 and any(x.startswith(g) for x in F.genes)) | |
| out = [] | |
| for p in re.split(r"\n\s*\n", text): | |
| p = " ".join(p.split()) | |
| if not p: continue | |
| p = re.sub(r"^[-*]\s+(?!\*)", "", p) # no list markers | |
| segs = re.split(r"(`[^`]*`)", p) # bare gene names of the facts get their style | |
| if gre: segs = [s if s.startswith("`") else gre.sub(r"`\1`", s) for s in segs] | |
| h = html.escape("".join(segs), quote=True) | |
| h = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", h) | |
| h = re.sub(r"(?<!\*)\*(?!\*)([^*]+?)(?<!\*)\*(?!\*)", r"\1", h) # single-asterisk italics: plain | |
| h = re.sub(r"`([^`]+)`", lambda mm: f'<span class="gene">{mm.group(1)}</span>' if known(html.unescape(mm.group(1))) else mm.group(1), h) | |
| h = re.sub(r"\[\[(.+?)\]\]", rf'<span class="interp" title="{INTERP_TITLE}">\1</span>', h) | |
| h = h.replace("**", "").replace("`", "").replace("[[", "").replace("]]", "") | |
| if not balanced(h): h = html.escape(re.sub(r"\*\*|\[\[|\]\]|`", "", p), quote=True) | |
| out.append(f'<p class="note">{h}</p>') | |
| return "".join(out) | |
| def balanced(h): | |
| st = [] | |
| for mm in re.finditer(r"<(/?)(b|span)\b[^>]*>", h): | |
| if not mm.group(1): st.append(mm.group(2)) | |
| elif not st or st.pop() != mm.group(2): return False | |
| return not st | |
| # ---------------------------------------------------------------- the LLM | |
| _TOKEN = [None, False] | |
| def token(): | |
| """HF_TOKEN, else the local login; read once, kept in memory only""" | |
| if not _TOKEN[1]: | |
| t = os.environ.get("HF_TOKEN") or None | |
| if not t: | |
| try: | |
| from huggingface_hub import get_token | |
| t = get_token() | |
| except Exception: t = None | |
| _TOKEN[0] = t; _TOKEN[1] = True | |
| return _TOKEN[0] | |
| class _NoRedirect(urllib.request.HTTPRedirectHandler): | |
| """a redirect is an error: the Authorization header is never sent to another address""" | |
| def redirect_request(self, *a, **k): return None | |
| OPENER = urllib.request.build_opener(_NoRedirect) | |
| def _bill(cost): | |
| with LOCK: _roll(); USAGE["cost"] += cost; USAGE["calls"] += 1 | |
| def llm(messages, deadline): | |
| """one completion: the providers in order, the next on a timeout, a redirect, a 429 or a 5xx. | |
| -> dict(text, finish, usage, provider, ms). Every call is counted in the day's dollars: a call cut by a timeout or | |
| without usage at its worst case (the prompt and MAX_TOKENS out). The router's error text is never passed on.""" | |
| tok = token() | |
| if not tok: raise Unavailable("no Hugging Face token on this server (HF_TOKEN)", code="no_token") | |
| errs = [] | |
| est_in = len(json.dumps(messages)) / 3.0 | |
| for prov in PROVIDERS: | |
| left = deadline - time.time() | |
| if left < 5: errs.append(f"{prov}: no time left"); break | |
| pin, pout = PRICES.get(prov, (0.3, 1.2)); worst = (est_in * pin + MAX_TOKENS * pout) / 1e6 | |
| body = dict(model=f"{MODEL}:{prov}", messages=messages, max_tokens=MAX_TOKENS, temperature=TEMPERATURE, | |
| chat_template_kwargs=dict(thinking=False), stream=False) | |
| req = urllib.request.Request(URL, data=json.dumps(body).encode(), method="POST", | |
| headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}) | |
| t0 = time.time() | |
| try: | |
| with OPENER.open(req, timeout=min(TIMEOUT, left)) as r: | |
| out = json.load(r); used = r.headers.get("x-inference-provider") or prov | |
| except urllib.error.HTTPError as e: | |
| if e.code in (401, 403): | |
| AUTH.update(t=time.time(), code="refused", why=f"the token was refused by the Inference Providers router (HTTP {e.code})") | |
| raise Unavailable(AUTH["why"], code="refused") | |
| if e.code == 402: | |
| AUTH.update(t=time.time(), code="credit", why="no Inference Providers credit left on this token's account (HTTP 402)") | |
| raise Unavailable(AUTH["why"], code="credit") | |
| errs.append(f"{prov}: HTTP {e.code}"); continue | |
| except Exception as e: # timeout, connection: maybe billed | |
| _bill(worst); errs.append(f"{prov}: {type(e).__name__}"); continue | |
| try: | |
| ch = out["choices"][0]; text = (ch["message"].get("content") or "").strip() | |
| except Exception: | |
| _bill(worst); errs.append(f"{prov}: malformed answer"); continue | |
| u = out.get("usage") or {} | |
| pin, pout = PRICES.get(used, (pin, pout)) | |
| cost = u.get("estimated_cost") | |
| if cost is None and u.get("prompt_tokens") is not None: | |
| cost = (u.get("prompt_tokens", 0) * pin + (u.get("completion_tokens") or MAX_TOKENS) * pout) / 1e6 | |
| if cost is None: cost = worst | |
| _bill(float(cost)) | |
| if not text: errs.append(f"{prov}: empty answer"); continue | |
| usage = dict(prompt_tokens=u.get("prompt_tokens"), completion_tokens=u.get("completion_tokens"), | |
| cached_tokens=(u.get("prompt_tokens_details") or {}).get("cached_tokens"), | |
| reasoning_tokens=(u.get("completion_tokens_details") or {}).get("reasoning_tokens"), cost_usd=round(float(cost), 6)) | |
| return dict(text=text, finish=ch.get("finish_reason"), usage=usage, provider=used, ms=round((time.time() - t0) * 1000)) | |
| raise Upstream("the reading model could not be reached (" + "; ".join(errs) + ")", 30) | |
| def _attempt(r, F): | |
| """one answer of the model: tidied, parsed into its sections, every sentence checked against the lines it cites, | |
| then the reading that would be served (its unsupported sentences dropped, trimmed to the word limit)""" | |
| text = tidy(r["text"], F) | |
| chk = check_cited(text, F) | |
| out, rep = resolve(chk) | |
| cut = r["finish"] == "length" | |
| drops = [dict(tag=sec["tag"], text=st["text"][:200], cites=st["cites"], opens=st["opens"], | |
| bad=[{k: v for k, v in b.items() if k != "context"} for b in st["bad"][:4]]) | |
| for sec in chk["sections"] for st in sec["sents"] if not st["ok"]] | |
| return dict(r, raw=r["text"], text=out or text, annotated=text, | |
| check={k: v for k, v in chk.items() if k not in ("sections", "_F", "_X")}, | |
| report=rep, dropped=drops, served=out, truncated=cut, _chk=chk, | |
| ok=bool(out) and not cut and not chk["structure"] and rep["dropped"] <= 1 # one sentence dropped, five | |
| and len(rep["sections"]) == len(SECTIONS) and not rep["unled"] # sections left, every paragraph | |
| and (rep.get("interp", 0) >= 1 or "Q" not in F.ids)) # led, one interpretation: no 2nd call | |
| def generate(F, wid): | |
| """-> the cache entry: status llm (the sections that hold against their citations) or fallback (too little of the | |
| text was supported, twice)""" | |
| deadline = time.time() + BUDGET | |
| sysmsg, sha = system_msg() | |
| msgs = [dict(role="system", content=sysmsg), dict(role="user", content=user_msg(F, wid))] | |
| attempts = [] | |
| for i in range(2): | |
| try: r = llm(msgs, deadline) | |
| except Upstream: | |
| if not attempts: raise | |
| break # the retry could not be made: fallback | |
| a = _attempt(r, F); attempts.append(a) | |
| if a["ok"]: break # only its length was over: the trimming fits it | |
| if i == 0: | |
| if deadline - time.time() < 12: break | |
| msgs += [dict(role="assistant", content=r["text"]), | |
| dict(role="user", content=retry_cited(a["_chk"], a["report"], a["truncated"]))] | |
| clean = [a for a in attempts if a["ok"]] | |
| served = [a for a in attempts if a["served"] and not a["truncated"]] | |
| best = (clean[0] if clean else min(served, key=lambda a: (a["report"]["dropped"] + a["report"]["unled"], | |
| -a["report"].get("interp", 0), -a["report"].get("covered", 0))) if served else attempts[-1]) | |
| ok = bool(best["served"]) and not best["truncated"] | |
| usage = dict(prompt_tokens=sum(a["usage"]["prompt_tokens"] or 0 for a in attempts), | |
| completion_tokens=sum(a["usage"]["completion_tokens"] or 0 for a in attempts), | |
| cost_usd=round(sum(a["usage"]["cost_usd"] for a in attempts), 6), ms=sum(a["ms"] for a in attempts)) | |
| c = best["check"]; rep = best["report"] | |
| e = dict(status="llm" if ok else "fallback", model=MODEL, provider=best["provider"], created=time.time(), usage=usage, | |
| attempts=[{k: v for k, v in a.items() if k != "_chk"} for a in attempts], | |
| checked=dict(ok=ok, numbers=c["numbers"], names=c["names"], words=rep["words"] if ok else c["words"], | |
| attempts=len(attempts), sentences=c["sentences"], cited=c["cited"], skill=sha, | |
| dropped=rep["dropped"], trimmed=rep["dropped_len"], repeats=rep.get("repeats", 0), unled=rep["unled"], | |
| sections=rep["sections"], | |
| interp=rep.get("interp", 0), covered=rep.get("covered", 0))) | |
| if ok: e.update(text=best["served"], html=to_html(best["served"], F)) | |
| else: | |
| e["reason"] = reason(c, best["truncated"], rep) | |
| e["checked"]["bad"] = [{k: v for k, v in b.items() if k != "context"} for b in c["bad"][:12]] | |
| e["checked"]["structure"] = c["structure"][:4] | |
| return e | |
| # ---------------------------------------------------------------- cache, limits, the route | |
| LOCK = threading.Lock() | |
| MEM = collections.OrderedDict() # window -> entry (with its facts hash) | |
| FACTS = collections.OrderedDict() # window -> (Facts, hash) | |
| INFLIGHT = {} # window -> dict(ev, waiters, err): the generation under way | |
| SLOTS = threading.BoundedSemaphore(MAX_PARALLEL) | |
| QUEUED = [0] # new generations waiting for a slot | |
| HITS = {} # client -> deque of its new generations' times (PER_IP_S) | |
| DAYHITS = {} # client -> its new generations today | |
| PRUNED = [0.0] | |
| AUTH = dict(t=0.0, why=None, code=None) | |
| USAGE = dict(day=None, n=0, cost=0.0, calls=0) | |
| _HOME = [None] | |
| def _safe(wid): return re.sub(r"[^A-Za-z0-9_.-]", "_", wid)[:80] | |
| def _paths(wid): | |
| ds = [CACHE_DIR] + ([BUNDLED] if os.path.abspath(BUNDLED) != os.path.abspath(CACHE_DIR) else []) | |
| return [os.path.join(d, _safe(wid) + ".json") for d in ds] | |
| def _today(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d") | |
| def _to_midnight(): | |
| n = datetime.datetime.now(datetime.timezone.utc) | |
| return int((n.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) - n).total_seconds()) + 1 | |
| def _roll(): | |
| """a new UTC day: the day's counters start again (under LOCK)""" | |
| if USAGE["day"] != _today(): USAGE.update(day=_today(), n=0, cost=0.0, calls=0); DAYHITS.clear() | |
| def _usage_load(): | |
| try: | |
| u = json.load(open(os.path.join(CACHE_DIR, "_usage.json"))) | |
| if u.get("day") == _today(): USAGE.update(day=u["day"], n=int(u["n"]), cost=float(u.get("cost", 0)), calls=int(u.get("calls", 0))) | |
| except Exception: pass | |
| _roll() | |
| def _write(path, obj): | |
| try: | |
| os.makedirs(os.path.dirname(path), exist_ok=True); tmp = f"{path}.{os.getpid()}.{threading.get_ident()}.tmp" | |
| with open(tmp, "w") as f: json.dump(obj, f, indent=1) | |
| os.replace(tmp, path) | |
| except OSError: pass # a read-only disk: memory only | |
| def home_data(): | |
| if _HOME[0] is None: _HOME[0] = json.load(open("pgb/graph_pgb.json")) | |
| return _HOME[0] | |
| def window_id(a): | |
| w = (a.get("window") or a.get("anchor") or "").strip() | |
| if not w: raise ValueError("give window=dnaA or anchor=<the window's anchor family>") | |
| if w.lower() == DNAA.lower(): return DNAA | |
| if not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", w): raise ValueError("unknown window") | |
| return w | |
| def facts_of(wid, region): | |
| with LOCK: | |
| if wid in FACTS: FACTS.move_to_end(wid); return FACTS[wid] | |
| D = home_data() if wid == DNAA else region(wid) | |
| F = facts(D, wid); sysmsg, sha = system_msg() # the skill's sha is part of the key: a new skill, a new reading | |
| h = hashlib.sha1((PROMPT_VERSION + MODEL + sha + sysmsg + user_msg(F, wid)).encode()).hexdigest()[:12] | |
| F.label = DNAA if wid == DNAA else D["meta"].get("anchor_label") or wid | |
| with LOCK: | |
| FACTS[wid] = (F, h) | |
| while len(FACTS) > 64: FACTS.popitem(last=False) | |
| return F, h | |
| def cached(wid, h): | |
| with LOCK: | |
| e = MEM.get(wid) | |
| if e is not None: MEM.move_to_end(wid) | |
| if e is None or e.get("facts_hash") != h: | |
| e = None | |
| for p in _paths(wid): | |
| try: x = json.load(open(p)) | |
| except Exception: continue | |
| if x.get("facts_hash") == h: e = x; break | |
| if e is None: return None | |
| if e["status"] == "fallback" and time.time() - e["created"] > FAIL_TTL: return None | |
| with LOCK: | |
| MEM[wid] = e | |
| while len(MEM) > 256: MEM.popitem(last=False) | |
| return e | |
| def availability(): | |
| """-> None when new readings can be written here, else (code, why)""" | |
| if os.environ.get("READING", "").lower() in ("off", "0", "no", "false") or PER_IP <= 0 or PER_IP_DAY <= 0: | |
| return "off", "new readings are turned off on this server (READING=off)" | |
| if not token(): return "no_token", "no Hugging Face token on this server (set the HF_TOKEN secret)" | |
| if AUTH["why"] and time.time() - AUTH["t"] < AUTH_RETRY: return AUTH["code"], AUTH["why"] | |
| _roll() | |
| if USAGE["n"] >= DAILY_CAP: return "cap", f"the {DAILY_CAP} new readings of the day are written (until 00:00 UTC)" | |
| if USAGE["cost"] >= DAILY_USD: return "spend", f"the day's ${DAILY_USD:g} for new readings is spent (until 00:00 UTC)" | |
| return None | |
| def _ip(x): | |
| """an X-Forwarded-For entry as an address: brackets and a port stripped; None if it is not one""" | |
| x = x.strip().strip('"') | |
| m = re.fullmatch(r"\[([0-9A-Fa-f:.]+)\](?::\d+)?", x) | |
| if m: x = m.group(1) | |
| elif re.fullmatch(r"[\d.]+:\d+", x): x = x.rsplit(":", 1)[0] | |
| try: return ipaddress.ip_address(x) | |
| except ValueError: return None | |
| def client_ip(peer, xff): | |
| """the client, for its limits. Behind a trusted proxy (a Hugging Face Space, SPACE_ID set, or TRUST_PROXY=1): the | |
| rightmost public address of X-Forwarded-For (all its lines joined), the one the proxy appended; the walk stops at an | |
| entry that is not an address, which is then the key (a client can only prepend). Else the peer. IPv6 by /64.""" | |
| key = None | |
| if TRUST_PROXY and xff: | |
| for x in reversed([s for s in xff.split(",") if s.strip()]): | |
| ip = _ip(x) | |
| if ip is None: key = "?" + x.strip()[:64]; break | |
| if ip.is_private or ip.is_loopback or ip.is_link_local: continue | |
| key = ip; break | |
| if key is None: key = _ip(peer or "") or (peer or "?") | |
| if isinstance(key, ipaddress.IPv6Address) and not key.ipv4_mapped: return str(ipaddress.ip_network(f"{key}/64", strict=False)) | |
| if isinstance(key, ipaddress.IPv6Address): return str(key.ipv4_mapped) | |
| return str(key) | |
| def site_ok(sfs, origin, host): | |
| """a new reading only for the page's own site: Sec-Fetch-Site same-origin (or none: typed in), an Origin of this | |
| host; locally (not a Space) also a page on localhost or opened as a file (Origin null). A request with no browser | |
| metadata (a script) passes: it has the same limits. Cached readings (peek, or written already) go to anyone.""" | |
| sfs = (sfs or "").strip().lower(); origin = (origin or "").strip() | |
| if sfs in ("same-origin", "none"): return True | |
| if origin: | |
| if origin == "null": return not SPACE | |
| o = urllib.parse.urlparse(origin).netloc.lower() | |
| if host and o == host.strip().lower(): return True | |
| return not SPACE and bool(re.fullmatch(r"(localhost|127\.0\.0\.1|\[::1\])(:\d+)?", o)) | |
| return sfs not in ("cross-site", "same-site") | |
| def _prune(now): | |
| """forget the clients with no new reading in the last PER_IP_S (under LOCK)""" | |
| if now - PRUNED[0] < 60: return | |
| PRUNED[0] = now | |
| for k in [k for k, q in HITS.items() if not q or now - q[-1] > PER_IP_S]: del HITS[k] | |
| def _admit(ip, commit): | |
| """may this client have a new generation now? commit: count it, for the client and the day""" | |
| now = time.time() | |
| with LOCK: | |
| why = availability() | |
| if why: raise Unavailable(why[1], _to_midnight() if why[0] in ("cap", "spend") else None, why[0]) | |
| _prune(now) | |
| q = HITS.get(ip) | |
| while q and now - q[0] > PER_IP_S: q.popleft() | |
| if q and len(q) >= PER_IP: | |
| raise Limited(f"{PER_IP} new readings per {PER_IP_S / 60:.0f} minutes from one visitor", int(PER_IP_S - (now - q[0])) + 1, "limit") | |
| d = DAYHITS.get(ip, 0) | |
| if d >= PER_IP_DAY: raise Limited(f"{PER_IP_DAY} new readings a day from one visitor", _to_midnight(), "limit_day") | |
| if commit: | |
| HITS.setdefault(ip, collections.deque()).append(now); DAYHITS[ip] = d + 1; USAGE["n"] += 1 | |
| def _refund(ip): | |
| """a generation that could not be made: the client may ask again (the day's count and dollars stay)""" | |
| with LOCK: | |
| q = HITS.get(ip) | |
| if q: q.pop() | |
| if DAYHITS.get(ip): DAYHITS[ip] -= 1 | |
| def _slot(): | |
| """one of the MAX_PARALLEL generation slots: a free one at once, else wait (at most QUEUE_MAX wait, SLOT_WAIT each)""" | |
| if SLOTS.acquire(blocking=False): return | |
| with LOCK: | |
| if QUEUED[0] >= QUEUE_MAX: raise Busy("other readings are being written", 10) | |
| QUEUED[0] += 1 | |
| try: ok = SLOTS.acquire(timeout=SLOT_WAIT) | |
| finally: | |
| with LOCK: QUEUED[0] -= 1 | |
| if not ok: raise Busy("other readings are being written", 5) | |
| def public(e, wid, F, cached_, with_facts=False): | |
| out = dict(window=wid, label=F.label, status=e["status"], model=e["model"], provider=e.get("provider"), cached=cached_, | |
| created=datetime.datetime.fromtimestamp(e["created"], datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), | |
| checked=e["checked"], usage=dict(e["usage"])) | |
| if e["status"] == "llm": out.update(html=e["html"], text=e["text"]) | |
| else: out["reason"] = e.get("reason", "") | |
| if with_facts: out["facts"] = F.txt() | |
| return out | |
| def answer(a, peer="", xff="", region=None, site=True): | |
| """-> (HTTP code, JSON) for GET /reading?window=dnaA | anchor=<family> [&peek=1] [&facts=1]; raises a ReadingError | |
| (its status and body()) or ValueError / KeyError (400). site: the request comes from the page's own site""" | |
| t0 = time.time(); wid = window_id(a) | |
| try: F, h = facts_of(wid, region) | |
| except KeyError as e: raise ValueError(e.args[0] if e.args else "unknown window") | |
| wf = a.get("facts") == "1" | |
| done = lambda e, c: (200, dict(public(e, wid, F, c, wf), ms=round((time.time() - t0) * 1000))) | |
| e = cached(wid, h) | |
| if e: return done(e, True) | |
| if a.get("peek") == "1": | |
| with LOCK: why = availability() | |
| out = dict(window=wid, label=F.label, status="none", model=MODEL, available=why is None) | |
| if why: out.update(code=why[0], reason=why[1]) | |
| if wf: out["facts"] = F.txt() | |
| return 200, out | |
| if not site: raise Refused("new readings are written only for the page of this server") | |
| ip = client_ip(peer, xff) | |
| with LOCK: u0 = (USAGE["n"], USAGE["calls"]) | |
| for _ in range(3): # one generation per window at a time | |
| with LOCK: | |
| job = INFLIGHT.get(wid); mine = job is None | |
| if mine: job = INFLIGHT[wid] = dict(ev=threading.Event(), waiters=0, err=None) | |
| elif job["waiters"] >= WAITERS: raise Busy("this window's reading is being written", 5) | |
| else: job["waiters"] += 1 | |
| if mine: break | |
| try: job["ev"].wait(BUDGET + SLOT_WAIT + 5) | |
| finally: | |
| with LOCK: job["waiters"] -= 1 | |
| e = cached(wid, h) | |
| if e: return done(e, True) | |
| err = job["err"] | |
| if isinstance(err, (Unavailable, Upstream)): raise type(err)(str(err), err.retry_s, err.code) | |
| # the first request was refused for itself (its limit, its site) or had to wait: this one tries on its own | |
| else: raise Busy("this window's reading is being written", 5) | |
| try: | |
| _admit(ip, False) | |
| _slot() | |
| try: | |
| _admit(ip, True) | |
| try: e = generate(F, wid) | |
| except (Upstream, Unavailable): _refund(ip); raise | |
| finally: SLOTS.release() | |
| e.update(window=wid, label=F.label, facts_hash=h, prompt_version=PROMPT_VERSION, skill_sha=skill()[1], facts=F.txt()) | |
| with LOCK: | |
| MEM[wid] = e | |
| while len(MEM) > 256: MEM.popitem(last=False) | |
| _write(_paths(wid)[0], e) | |
| return done(e, False) | |
| except ReadingError as x: | |
| job["err"] = x; raise | |
| finally: | |
| with LOCK: | |
| INFLIGHT.pop(wid, None); u = dict(USAGE) | |
| job["ev"].set() | |
| if (u["n"], u["calls"]) != u0: _write(os.path.join(CACHE_DIR, "_usage.json"), u) | |
| def health(): | |
| with LOCK: | |
| why = availability(); n = len(MEM); clients = len(HITS); u = dict(USAGE) | |
| nf = set() | |
| for d in {CACHE_DIR, BUNDLED}: | |
| try: nf |= {f for f in os.listdir(d) if f.endswith(".json") and not f.startswith("_")} | |
| except OSError: pass | |
| out = dict(available=why is None, model=MODEL, providers=PROVIDERS, max_tokens=MAX_TOKENS, cached=max(n, len(nf)), | |
| prompt=dict(version=PROMPT_VERSION, skill=skill()[1], skill_file=_SKILL[2], temperature=TEMPERATURE, words=WORDS_MAX), | |
| today=dict(new=u["n"], cap=DAILY_CAP, calls=u["calls"], cost_usd=round(u["cost"], 4), usd_cap=DAILY_USD), | |
| per_client=dict(new=PER_IP, per_s=int(PER_IP_S), per_day=PER_IP_DAY, clients=clients, proxy=TRUST_PROXY)) | |
| if why: out.update(reason=why[1], code=why[0]) | |
| return out | |
| _usage_load() | |
| def pregen(args): | |
| """write the readings of windows ahead, without the server (python3 pg_reading.py pregen ...)""" | |
| import concurrent.futures as cf | |
| import pgb_region as RG | |
| dry = "--dry" in args; ws = [a for a in args if not a.startswith("--")] | |
| if "--chain" in args: # the page's Next from the dnaA window, then Next again; Previous from each | |
| a = RG.resolve("60"); seen = [] | |
| while a is not None and a not in seen: | |
| seen.append(a); _, nx = RG.neighbours(a, 60); a = RG.FIDX[nx["fam"]] if nx else None | |
| prev = [RG.FIDX[p["fam"]] for p in (RG.neighbours(a, 60)[0] for a in seen) if p and p["pos"] != 0] | |
| ws += [RG.FN[a] for a in seen] + [RG.FN[a] for a in prev if a not in seen] | |
| ws = list(dict.fromkeys(ws)) | |
| region = lambda w: RG.build(RG.FIDX[w]) | |
| todo = [] | |
| for w in ws: | |
| wid = DNAA if w.lower() == DNAA.lower() else w | |
| if wid != DNAA and RG.FIDX.get(wid) not in RG.BB_RANK: print(f"{w}: not a window anchor", flush=True); continue | |
| F, h = facts_of(wid, region) | |
| if cached(wid, h): continue | |
| todo.append((wid, F, h)) | |
| print(f"{len(ws)} windows, {len(todo)} to write", flush=True) | |
| if dry: return | |
| tot = dict(cost=0.0, n=0, fb=0) | |
| def one(x): | |
| wid, F, h = x; t = time.time() | |
| try: e = generate(F, wid) | |
| except ReadingError as err: return wid, f"error {err.code}: {err}", None | |
| e.update(window=wid, label=F.label, facts_hash=h, prompt_version=PROMPT_VERSION, skill_sha=skill()[1], facts=F.txt()) | |
| _write(_paths(wid)[0], e) | |
| return wid, f"{e['status']} {e['checked']['attempts']} attempt(s) ${e['usage']['cost_usd']:.5f} {time.time() - t:.1f}s", e | |
| with cf.ThreadPoolExecutor(MAX_PARALLEL) as ex: | |
| for wid, msg, e in ex.map(one, todo): | |
| if e: tot["cost"] += e["usage"]["cost_usd"]; tot["n"] += 1; tot["fb"] += e["status"] == "fallback" | |
| print(wid, msg, flush=True) | |
| print(f"written {tot['n']} ({tot['fb']} fallback), ${tot['cost']:.4f}; calls counted ${USAGE['cost']:.4f}", flush=True) | |
| if __name__ == "__main__": | |
| import sys | |
| if len(sys.argv) >= 3 and sys.argv[1] == "facts": | |
| w = sys.argv[2]; D = home_data() if w == DNAA else json.load(open(w)) | |
| F = facts(D, DNAA if w == DNAA else D["meta"]["anchor"]); t = F.txt() | |
| print(t); print(f"\n[{len(t)} characters, ~{len(t) // 4} tokens; {sum(map(len, F.nums.values()))} numbers, {len(F.genes)} genes]", file=sys.stderr) | |
| elif len(sys.argv) >= 4 and sys.argv[1] == "check": | |
| w = sys.argv[2]; D = home_data() if w == DNAA else json.load(open(w)) | |
| F = facts(D, DNAA if w == DNAA else D["meta"]["anchor"]) | |
| chk = check_cited(tidy(open(sys.argv[3]).read(), F), F); out, rep = resolve(chk) | |
| for sec in chk["sections"]: | |
| for st in sec["sents"]: | |
| print(f"{'ok ' if st['ok'] else 'DROP'} @{sec['tag']:8s} {{{', '.join(st['cites'])}}} {st['text'][:110]}") | |
| for b in st["bad"]: print(f" - {b['kind']}: {b['text']} — {b['why']}") | |
| print(json.dumps(dict({k: v for k, v in chk.items() if k not in ("sections", "bad", "notes")}, report=rep), indent=1)) | |
| old = check(render(chk["sections"]), F) # the whole-text check of r7, for comparison | |
| print(f"the whole-text check: {'ok' if old['ok'] else 'not ok'}, {len(old['bad'])} item(s) " | |
| + ", ".join(sorted({b['kind'] for b in old['bad']}))) | |
| print("\n" + (out or "(nothing servable)")) | |
| elif len(sys.argv) >= 2 and sys.argv[1] == "skill": | |
| t, sha = skill(); print(f"[{SKILL_PATH} {'read' if _SKILL[2] else 'MISSING'}, sha {sha}, {len(t)} characters, ~{len(t) // 4} tokens]") | |
| sysmsg, _ = system_msg(); print(f"[system message {len(sysmsg)} characters, ~{len(sysmsg) // 4} tokens]") | |
| elif len(sys.argv) >= 2 and sys.argv[1] == "pregen": pregen(sys.argv[2:]) | |
| else: print(__doc__) | |