Skip to content

Commit 03f9dee

Browse files
committed
feat(sentiment): promote Qwen3.8 27B to the panel as a fifth voice
Moves the self-hosted route from PILOT_CANDIDATES into PANEL, which is what makes it writable -- 01 iterates that dict alone. Authority item 111933, created on the archive today; AI_MODEL_ITEMS is keyed on the self-hosted route because that is what produced the annotations, and the OpenRouter twin gets its own item the day it writes anything. It joins rather than replaces. It shares no lab or pretraining family with the other four, so it does not carry the correlated-error problem that kept Gemma from being added beside Gemini, and it is the first member whose requested 'medium' is a rung the model has rather than one it is rounded up to. Four of the five are now open-weights, and this one goes furthest: its answers were produced on university hardware, so the run reproduces without a third party consenting to serve the model. Its coverage is 12,098 of 12,251 and is expected to stay there. The 153 that failed four times each are documented in PANEL as a gap not to repair, because the obvious reading of a short member -- a failed run, re-run it -- is wrong here and would cost a day. The OpenRouter twin stays a candidate. test_the_two_qwen_routes_stay_on_opposite _sides_of_the_panel now pins that arrangement in both directions: same weights, different routes, and only one of them writable. Also fix a premise 00_setup_properties.py had backwards. Its emit_ttl docstring warned that Omeka deletes any installed property the uploaded file omits, along with every value under it. It does not -- a vocabulary update applies additions only, so the archive holds a superset of the file (80 properties against 38) and an omission destroys nothing. The --verify output said "Would be DELETED" and counted deletions; it now reports what the file omits and states plainly that omitted is not deleted. The check itself still runs before every upload: it answers whether anything omitted still holds values, which is the question that matters if the properties are ever pruned by hand, and the one that would catch a member dropped from PANEL by mistake.
1 parent 00d4002 commit 03f9dee

4 files changed

Lines changed: 112 additions & 42 deletions

File tree

AI_sentiment_analysis/00_setup_properties.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -228,13 +228,20 @@ def _ttl_block(definition: PropertyDef) -> str:
228228
def emit_ttl() -> str:
229229
"""The Turtle to append to ``iwac-vocabulary.ttl``.
230230
231-
Emits the active panel, and only it. **Omeka deletes any installed property
232-
the uploaded file omits, along with every value stored under it**, so this
233-
function decides what survives the next vocabulary upload: a member dropped
234-
from ``PANEL`` while still holding annotations would have them destroyed
235-
silently. Every retired member has now been emptied deliberately, so there
236-
is nothing left for an omission to take — but check that before removing the
237-
next one, not after.
231+
Emits the active panel, and only it — so a member dropped from ``PANEL``
232+
disappears from the file, and the file stops describing the live vocabulary
233+
rather than pruning it. **Omeka applies additions only**: a vocabulary
234+
update never removes an installed property, so the archive holds a superset
235+
of this file (80 properties against 38, measured 2026-08-25) and omitting a
236+
member destroys nothing.
237+
238+
That is worth stating precisely because the opposite was believed here for
239+
months, and it is the more dangerous belief to hold backwards in either
240+
direction. Fearing a deletion that cannot happen means keeping retired
241+
members in the file forever; assuming one that *could* happen means an
242+
omission silently taking values with it. ``--verify`` resolves it by
243+
measurement rather than by either assumption: it counts what every omitted
244+
property holds and refuses the upload if any of them holds anything.
238245
"""
239246
out: List[str] = [
240247
"# ============================================",
@@ -333,17 +340,17 @@ def verify_ttl_is_superset(client: OmekaClient, ttl_path: Path) -> bool:
333340
table.add_column("Count", justify="right")
334341
table.add_row("Installed in Omeka", str(len(installed_names)))
335342
table.add_row("Declared in the .ttl", str(len(declared)))
336-
table.add_row("Would be added", f"[green]{len(would_add)}[/]")
343+
table.add_row("Will be added", f"[green]{len(would_add)}[/]")
337344
table.add_row(
338-
"Would be DELETED",
339-
f"[bold red]{len(would_delete)}[/]" if would_delete else "[dim]0[/]",
345+
"Omitted by the .ttl",
346+
f"[bold]{len(would_delete)}[/]" if would_delete else "[dim]0[/]",
340347
)
341348
console.print(table)
342349

343350
populated: Dict[str, int] = {}
344351
if would_delete:
345352
console.print("\n[yellow]The .ttl omits properties that are installed. "
346-
"Checking what would be lost:[/]")
353+
"Checking what each holds:[/]")
347354
for name in would_delete:
348355
count = count_values(client, installed_names[name])
349356
if count:
@@ -353,7 +360,7 @@ def verify_ttl_is_superset(client: OmekaClient, ttl_path: Path) -> bool:
353360

354361
if populated:
355362
console.print(Panel(
356-
"These carry values that the upload would destroy:\n\n "
363+
"These are omitted by the .ttl and still carry values:\n\n "
357364
+ "\n ".join(f"{VOCABULARY_PREFIX}:{n}{c:,} item(s)"
358365
for n, c in sorted(populated.items()))
359366
+ "\n\nDo not upload. Add them back to the .ttl, or export the "
@@ -364,13 +371,24 @@ def verify_ttl_is_superset(client: OmekaClient, ttl_path: Path) -> bool:
364371

365372
summary = f"{len(would_add)} additions"
366373
if would_delete:
367-
summary += f", {len(would_delete)} deletions — all verified empty"
368-
else:
369-
summary += ", 0 deletions"
374+
summary += f", {len(would_delete)} omitted — all verified empty"
370375
console.print(f"\n[green]✓[/] Safe to upload — {summary}.")
371376
console.print("[dim] Admin → Vocabularies → IWAC Ontology → Update. "
372-
"Omeka shows the same diff before committing; if its list "
373-
"differs from the one above, stop.[/]")
377+
"Omeka shows its own diff before committing; if its list of "
378+
"additions differs from the one above, stop.[/]")
379+
if would_delete:
380+
console.print(Panel(
381+
f"The {len(would_delete)} omitted properties will stay installed. "
382+
"Omeka does not remove a vocabulary property on update — it applies "
383+
"additions only — so the live vocabulary is a superset of this file "
384+
"and stays one.\n\n"
385+
"The check above still has to run, because it is the only thing "
386+
"standing between an omission and a silent loss if that behaviour "
387+
"ever changes, or if the properties are removed by hand later. It "
388+
"answers \"is anything omitted still holding values\", not \"will "
389+
"Omeka delete these\" — the answer to the second is no.",
390+
title="Omitted ≠ deleted", border_style="dim",
391+
))
374392
return True
375393

376394

AI_sentiment_analysis/sentiment_core.py

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -243,11 +243,15 @@ def probe_term(self) -> str:
243243
#: for the model, never for its vendor: reusing a vendor slot is what made
244244
#: generation 1 impossible to attribute without a git archaeology session.
245245
#:
246-
#: Three of the four are open-weights releases (Mistral Small 4 and Gemma 4
247-
#: under Apache-2.0, DeepSeek V4 Flash under MIT), so those annotations can be
248-
#: regenerated from weights that are archivable alongside them. Their active
249-
#: parameter counts — 6.5B, 13B and 31B (Gemma is dense, so all of it) — sit
250-
#: inside a factor of five, wider than the pair that preceded them.
246+
#: Four of the five are open-weights releases (Mistral Small 4, Gemma 4 and
247+
#: Qwen3.8 under Apache-2.0, DeepSeek V4 Flash under MIT), so those annotations
248+
#: can be regenerated from weights that are archivable alongside them. Their
249+
#: active parameter counts — 6.5B, 13B, 27B and 31B (Gemma is dense, so all of
250+
#: it) — sit inside a factor of five, wider than the pair that preceded them.
251+
#: Only GPT-5.6 Luna is a model that could be withdrawn out from under its own
252+
#: annotations, and Qwen3.8 goes further than the other three: its answers were
253+
#: produced on hardware in the building, so the run is reproducible without a
254+
#: third party consenting to serve the weights at all.
251255
#:
252256
#: Every member is its vendor's high-volume tier. That is the property that
253257
#: makes this a panel rather than a quality ladder, and it is why the Gemini
@@ -310,9 +314,38 @@ def probe_term(self) -> str:
310314
"mistralSmall2603"),
311315
PanelMember("deepseek_v4_flash_0731", "deepseek-v4-flash-0731",
312316
"DeepSeek V4 Flash 0731", "deepseekV4Flash0731"),
317+
PanelMember("qwen3_8_27b", "qwen3.8-27b-selfhosted",
318+
"Qwen3.8 27B (self-hosted)", "qwen3827b"),
313319
)
314320
}
315321

322+
#: **Qwen3.8 27B joined as a fifth voice on 2026-08-25**, annotated on
323+
#: university hardware rather than through anyone's API — the first member whose
324+
#: answers cost queue time instead of tokens, and the first whose requested
325+
#: ``medium`` is a rung the model actually has rather than one it is rounded up
326+
#: to (see :data:`PANEL_REASONING`). It joins rather than replaces: it shares no
327+
#: lab or pretraining family with the other four, so it does not carry the
328+
#: correlated-error problem that kept Gemma from being added beside Gemini.
329+
#:
330+
#: Its registry key names the *route*, not just the weights. The OpenRouter twin
331+
#: stays in :data:`PILOT_CANDIDATES` and must not be promoted alongside it: the
332+
#: same weights reached two ways would look like two annotators in the panel
333+
#: while being one reading of the construct, which is the correlated-error
334+
#: failure again in its purest form.
335+
#:
336+
#: **Its coverage is 12,098 of 12,251 and is expected to stay there.** 153
337+
#: articles were attempted four times each — a full-corpus pass plus three retry
338+
#: rounds — and retired. 145 of them fail the schema's cross-field rule the same
339+
#: way every time, and the failures concentrate on low centrality (5.45% of
340+
#: ``Marginal`` against 0.00% of ``Non abordé``): the model declines subjectivité
341+
#: when Islam is *peripheral* to an article where the prompt licenses declining
342+
#: only when it is *absent*. That is a disagreement about the instrument, so it
343+
#: is recorded rather than repaired — ``serving/merge_shards.py`` writes the
344+
#: failure log, and the README explains why relaxing the validator would be
345+
#: worse than the gap. **Do not "fix" this member's shortfall by re-running it.**
346+
#: The consequence for analysis is that its missing subjectivité is not missing
347+
#: at random.
348+
316349
#: Models under evaluation, which ``02_pilot_new_panel.py`` runs and nothing
317350
#: else does. Membership of :data:`PANEL` is what makes a model *writable* —
318351
#: ``01_sentiment_analysis.py`` iterates that dict alone — so a candidate parked
@@ -331,28 +364,32 @@ def probe_term(self) -> str:
331364
#: comes first, then the code that cites it (see ``AI_MODEL_ITEMS``). Nothing in
332365
#: the pilot path asks for it; only the write path does.
333366
#:
334-
#: **Qwen3.8 27B appears twice, once per route.** The property prefix names the
335-
#: model and the registry key carries the route, exactly as ``gemma_4_31b_it``
336-
#: does for ``gemma-4-openrouter``. Running both on one sample is what turns
337-
#: "self-hosting is cheaper" into a measurement: same weights, same articles,
338-
#: with latency, structured-output reliability and reasoning depth read off each
339-
#: route rather than assumed to match. Cost is not comparable in one unit —
340-
#: OpenRouter bills tokens, a cluster bills GPU-hours and queue time — so record
341-
#: both rather than converting one into the other.
367+
#: **The remaining candidate is Qwen3.8 27B's OpenRouter twin, and it stays a
368+
#: candidate even though the same weights are now in :data:`PANEL`.** The
369+
#: property prefix names the model and the registry key carries the route,
370+
#: exactly as ``gemma_4_31b_it`` does for ``gemma-4-openrouter``. Running both on
371+
#: one sample is what turns "self-hosting is cheaper" into a measurement: same
372+
#: weights, same articles, with latency, structured-output reliability and
373+
#: reasoning depth read off each route rather than assumed to match. Cost is not
374+
#: comparable in one unit — OpenRouter bills tokens, a cluster bills GPU-hours
375+
#: and queue time — so record both rather than converting one into the other.
376+
#:
377+
#: What it must never become is a fifth-and-a-half voice. Promoting it beside the
378+
#: self-hosted member would put one model in the panel twice, and a panel that
379+
#: counts one reading as two is measuring its own routing rather than the
380+
#: construct.
342381
#:
343-
#: Neither needs a :data:`PANEL_REASONING_OVERRIDES` entry. Qwen3.8's ladder is
382+
#: It needs no :data:`PANEL_REASONING_OVERRIDES` entry. Qwen3.8's ladder is
344383
#: low/medium/xhigh, so the panel's requested ``medium`` is a rung the model
345-
#: actually has: it would be the first member since GPT-5.6 Luna to sit at the
346-
#: requested depth instead of being rounded up to it. Whether that survives the
347-
#: OpenRouter route is precisely what the twin is there to find out — Gemma's
384+
#: actually has — verified on the self-hosted route, where reasoning length grew
385+
#: ~3.5× from ``low`` to ``xhigh`` with the middle rung cleanly between. Whether
386+
#: that survives the OpenRouter route is precisely what this twin is for: Gemma's
348387
#: graduated levels collapsed to on/off when fanned across third-party backends
349-
#: (see :data:`PANEL_REASONING`), and a self-hosted server is the only route here
350-
#: where the depth can be read off the server's own logs.
388+
#: (see :data:`PANEL_REASONING`), and a server you run yourself is the only route
389+
#: here where the depth can be read off the server's own logs.
351390
PILOT_CANDIDATES: Dict[str, PanelMember] = {
352391
m.key: m
353392
for m in (
354-
PanelMember("qwen3_8_27b", "qwen3.8-27b-selfhosted",
355-
"Qwen3.8 27B (self-hosted)", "qwen3827b"),
356393
PanelMember("qwen3_8_27b_openrouter", "qwen3.8-27b-openrouter",
357394
"Qwen3.8 27B (OpenRouter)", "qwen3827bOr"),
358395
)

common/iwac_config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@
147147
"mistral-small": {"item_id": 79614, "display_title": "Mistral Small 4"},
148148
"qwen3.5-moe": {"item_id": 79616, "display_title": "Qwen3.5 122B-A10B"},
149149
"qwen3.5-moe-small": {"item_id": 79612, "display_title": "Qwen3.5 35B-A3B"},
150+
# Keyed on the self-hosted route, which is the one that produced the
151+
# annotations: they were generated on university hardware, and the OpenRouter
152+
# twin (``qwen3.8-27b-openrouter``) is deliberately absent because it has
153+
# written nothing. The route is half of what the provenance record claims,
154+
# so the day the twin annotates anything it needs its own authority item
155+
# rather than a share of this one. Created 2026-08-25.
156+
"qwen3.8-27b-selfhosted": {"item_id": 111933, "display_title": "Qwen3.8 27B"},
150157
"deepseek-v4-flash-0731": {
151158
"item_id": 83261,
152159
"display_title": "DeepSeek V4 Flash 0731",

tests/test_sentiment_panel.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,15 +191,20 @@ def test_pilot_candidate_terms_collide_with_nothing():
191191
seen[term] = member.key
192192

193193

194-
def test_pilot_candidates_name_the_model_and_the_route():
194+
def test_the_two_qwen_routes_stay_on_opposite_sides_of_the_panel():
195195
"""Two routes to one set of weights, told apart by registry key.
196196
197197
The property prefix names the model and the registry key names the route —
198198
the split ``gemma_4_31b_it`` → ``gemma-4-openrouter`` already makes. Running
199199
both is what turns "self-hosting is cheaper" into something measured on one
200200
sample rather than assumed.
201+
202+
Since 2026-08-25 the self-hosted route is a panel member and the OpenRouter
203+
twin is still a candidate, which is the arrangement this pins. Promoting the
204+
twin as well would put one model in the panel twice — a panel that counts
205+
one reading of the construct as two is measuring its own routing.
201206
"""
202-
selfhosted = PILOT_CANDIDATES["qwen3_8_27b"]
207+
selfhosted = PANEL["qwen3_8_27b"]
203208
hosted = PILOT_CANDIDATES["qwen3_8_27b_openrouter"]
204209

205210
assert selfhosted.registry_key == "qwen3.8-27b-selfhosted"
@@ -209,6 +214,9 @@ def test_pilot_candidates_name_the_model_and_the_route():
209214
# served model is the same one.
210215
assert MODEL_REGISTRY[hosted.registry_key].model.lower() \
211216
== MODEL_REGISTRY[selfhosted.registry_key].model.lower()
217+
# ...which is exactly why only one of them may be writable.
218+
assert "qwen3_8_27b_openrouter" not in PANEL
219+
assert selfhosted.property_prefix != hosted.property_prefix
212220

213221

214222
def test_the_pilot_runs_candidates_alongside_the_live_panel():
@@ -442,7 +450,7 @@ def test_cache_round_trips(tmp_path, good_result):
442450

443451

444452
def test_cache_is_granular_to_the_model(tmp_path, good_result):
445-
"""One model failing must not force re-running the other four."""
453+
"""One model failing must not force re-running the rest of the panel."""
446454
path = tmp_path / "c.jsonl"
447455
with SentimentCache(path=path) as cache:
448456
for key in ("gemma_4_31b_it", "gpt_5_6_luna"):
@@ -451,7 +459,7 @@ def test_cache_is_granular_to_the_model(tmp_path, good_result):
451459
reloaded = SentimentCache(path=path)
452460
reloaded.load()
453461
assert reloaded.missing_models(1, PANEL) == [
454-
"mistral_small_2603", "deepseek_v4_flash_0731",
462+
"mistral_small_2603", "deepseek_v4_flash_0731", "qwen3_8_27b",
455463
]
456464

457465

0 commit comments

Comments
 (0)