diff --git a/backend/AGENTS.md b/backend/AGENTS.md index ece7bfc..23ecc92 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -79,7 +79,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN each re-checking that Site's bookmarked series' newest published chapter from backend's own network access, so `latest_chapter` stays fresh when the user isn't browsing. Second, parallel signal — the userscript keeps its own - `maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged. + `maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` schedule, and its + `reportLatestChapter` PUTs every read, unchanged numbers included, because an + unchanged read is exactly the Sighting worth deferring a Poll on (#103). Two independent clocks: per-series rest (`series.latest_checked_at`, enforced by `Store.DueForLatestCheck`'s WHERE clause — `now - Rest`) and per-Lane gap (the Lane sleeping between fetches, `effectiveGap`). Both live @@ -98,17 +100,23 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN `series.latest_sighted_at` and, when the report raises the stored number, names its Reader in `series.latest_raised_by`. The due query's HAVING clause is where deferral lives: a Series is skipped only while it has exactly one - Bookmark, was sighted within one Rest, and is under the `sightingCeiling` - (six rests) since its last Poll. So a shared Series is never deferred, and no - Series goes six hours unpolled whatever arrives. `checkOne` judges the named - Reader off the comparison it already makes: a lower number is a - contradiction (logged with the Reader and both numbers), the same number an - agreement, a higher number the Site publishing and neither. Three - contradictions (`store.SightingDisagreementLimit`) stop that Reader deferring - — their reports still write the Latest Chapter — and twenty consecutive - agreements (`store.SightingAgreementsToClear`) forgive them, as does the - owner's clear-marks control. Deferral is recomputed from live facts every - round, so nothing needs invalidating when a Series gains a second Bookmark. + Bookmark, was sighted within one Rest, and is under the ceiling + (`sightingCeilingRests`, six of that Site's rests) since its last Poll. So a + shared Series is never deferred, and no Series goes six hours unpolled + whatever arrives. `checkOne` judges the named Reader off the comparison it + already makes: a lower number is a contradiction (logged with the Reader and + both numbers), the same number an agreement, a higher number the Site + publishing and neither — that last one clears the attribution instead, since + the value the Poll then stores is its own and a later retraction is not the + Reader's fault. Three contradictions + (`store.SightingDisagreementLimit`) stop that Reader deferring — their + reports still write the Latest Chapter — and twenty consecutive agreements + (`store.SightingAgreementsToClear`) forgive them, as does the owner's + clear-marks control. Deferral is recomputed from live facts every round, so + nothing needs invalidating when a Series gains a second Bookmark; the one + input read earlier is the Reader's marks, checked when the Sighting is + recorded, so crossing the threshold or being cleared takes effect from that + Reader's next Sighting and the standing already bought lasts out its rest. Refusals and browser loss are Lane-local: two `errChallengeHeld` in one pass stop that Site for `refuseBackoff` (15m) while other Lanes continue; an `errBrowserInterrupted` (remote Chrome restart) sets a shared Poller flag diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index de79d2a..38bf0ca 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -250,8 +250,8 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. return defaultGap } - due, err := p.Store.DueForLatestCheck(name, - now.Add(-s.Rest).UnixMilli(), now.Add(-sightingCeiling).UnixMilli()) + due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli(), + now.Add(-sightingCeilingRests*s.Rest).UnixMilli()) if err != nil { log.Printf("latest poll %s: due query: %v", name, err) st.Gap = defaultGap @@ -503,6 +503,13 @@ func (p *Poller) judgeSighting(sr store.Series, found float64) { } stored := *sr.LatestChapterNum if found > stored { + // The report is neither confirmed nor contradicted, but it is answered: + // the value about to be stored is the Poll's own, so leaving the + // attribution would credit this Reader with the next Poll's agreement + // and blame them if the Site later retracts. + if err := p.Store.ClearSightingAttribution(sr.Site, sr.SeriesID, *sr.LatestRaisedBy); err != nil { + log.Printf("latest poll %q: clear sighting attribution: %v", sr.Key(), err) + } return } if found < stored { diff --git a/backend/internal/latest/sighting_test.go b/backend/internal/latest/sighting_test.go index 17f5c7d..e4fcea0 100644 --- a/backend/internal/latest/sighting_test.go +++ b/backend/internal/latest/sighting_test.go @@ -189,7 +189,7 @@ func TestSightingCeilingForcesPoll(t *testing.T) { newTestPoller(t, s, f, now).runOnce(context.Background()) if got := f.callCount(); got != 1 { - t.Fatalf("fetched %d times past the %s ceiling, want 1", got, sightingCeiling) + t.Fatalf("fetched %d times past the %s ceiling, want 1", got, sightingCeilingRests*defaultRest) } } @@ -439,3 +439,56 @@ func TestPollFindingHigherNumberIsNotAContradiction(t *testing.T) { t.Fatalf("a Reader whose report the Site overtook lost the right to defer (fetches %d, want 1)", got) } } + +// A Poll that overtakes a Sighting takes ownership of the row: the value stored +// afterwards is the Poll's own, so a later retraction is not the Reader's fault +// and must not be charged to them. +func TestAttributionDoesNotSurviveAPollThatOvertookIt(t *testing.T) { + s, _ := newTestStore(t) + now := time.UnixMilli(20 * time.Hour.Milliseconds()) + seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli()) + sight(t, s, s.OwnerID(), demonicKey, 295, now.Add(-10*time.Minute)) + + f := &fakeFetcher{body: demonicFixture(296), status: 200} + p := newTestPoller(t, s, f, now) + at := now.Add(defaultRest + time.Minute) + p.Now = func() time.Time { return at } + p.runOnce(context.Background()) + if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != 296 { + t.Fatalf("latest after the Poll = %v, want the Site's own 296", got) + } + + var logs strings.Builder + prev := log.Writer() + log.SetOutput(&logs) + t.Cleanup(func() { log.SetOutput(prev) }) + + f.body = demonicFixture(290) // the Site retracts what only the Poll wrote + p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) } + p.runOnce(context.Background()) + if strings.Contains(logs.String(), "sighting contradicted") { + t.Fatalf("a retraction of the Poll's own value was charged to a Reader: %s", logs.String()) + } +} + +// A PUT with no Latest Chapter in it — a favourite toggle, progress written +// from a chapter page — is nobody looking at the Series page, so it buys no +// deferral. Otherwise a client could suppress a Series' Polls while reporting +// nothing, and with nothing reported there would be nothing to judge. +func TestPutWithoutALatestChapterDoesNotDefer(t *testing.T) { + s, _ := newTestStore(t) + now := time.UnixMilli(20 * time.Hour.Milliseconds()) + seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli()) + // The handler's own call, with the field the client omitted. + if err := s.RecordSighting(s.OwnerID(), "demonic", "Catastrophic-Necromancer", + nil, now.Add(-time.Minute).UnixMilli()); err != nil { + t.Fatalf("RecordSighting: %v", err) + } + + f := &fakeFetcher{body: demonicFixture(296), status: 200} + p := newTestPoller(t, s, f, now) + p.runOnce(context.Background()) + if got := f.callCount(); got != 1 { + t.Fatalf("fetched %d times after a PUT carrying no chapter, want 1", got) + } +} diff --git a/backend/internal/latest/sites.go b/backend/internal/latest/sites.go index e824909..64580b4 100644 --- a/backend/internal/latest/sites.go +++ b/backend/internal/latest/sites.go @@ -409,13 +409,14 @@ const ( // asleep (ADR-0005 on-demand browser). browserWakeCount = 5 browserWakeAge = 15 * time.Minute - // sightingCeiling caps Sighting deferral (issue #103): however many - // Sightings arrive, a Series unpolled for this long is Polled. It is what - // makes a client report safe to trust — a wrong Latest Chapter dies within - // the ceiling deterministically, rather than in expectation the way a - // randomised audit would have it. Six rests, so a Series a Reader visits - // constantly still gets one authoritative check per working day-part. - sightingCeiling = 6 * defaultRest + // sightingCeilingRests caps Sighting deferral (issue #103): however many + // Sightings arrive, a Series unpolled for this many of its Site's rests is + // Polled. It is what makes a client report safe to trust — a wrong Latest + // Chapter dies within the ceiling deterministically, rather than in + // expectation the way a randomised audit would have it. Six, so a Series a + // Reader visits constantly still gets one authoritative check per working + // day-part. + sightingCeilingRests = 6 ) // effectiveGap is a Site's pace: the registry gap, or one rest divided by the diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 80e83bb..f102b63 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -589,9 +589,8 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) { } // scanSeries reads one row in seriesColumns order, plus the due query's -// reader_count column. latest_chapter_num is NULL until the first capture, -// same as on the bookmark read path, and latest_raised_by is NULL whenever no -// Sighting is currently answerable for the stored value. +// reader_count column. latest_chapter_num and latest_raised_by are both +// nullable, same as latest_chapter_num on the bookmark read path. func scanSeries(scan func(...any) error) (Series, error) { var ( sr Series @@ -1087,11 +1086,13 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro // including for a marked Reader — their Sightings are still judged, which is // how they earn the privilege back. // -// num is the reported chapter number, nil when the client sent none. +// num is the reported chapter number. A PUT that carries none — a favourite +// toggle, or progress written from a chapter page — is no Sighting at all: +// nobody read the Series page, so there is nothing to stand in for a Poll and +// nothing that could later be judged. func (s *Store) RecordSighting(readerID int64, site, seriesID string, num *float64, ts int64) error { - var reported any - if num != nil { - reported = *num + if num == nil { + return nil } if _, err := s.db.Exec(` UPDATE series SET @@ -1099,11 +1100,10 @@ func (s *Store) RecordSighting(readerID int64, site, seriesID string, num *float WHEN (SELECT sighting_disagreements FROM readers WHERE id = $3) < $6 THEN $4::bigint ELSE latest_sighted_at END, latest_raised_by = CASE - WHEN $5::double precision IS NOT NULL - AND (latest_chapter_num IS NULL OR $5::double precision > latest_chapter_num) + WHEN latest_chapter_num IS NULL OR $5::double precision > latest_chapter_num THEN $3::bigint ELSE latest_raised_by END WHERE site = $1 AND series_id = $2`, - site, seriesID, readerID, ts, reported, SightingDisagreementLimit); err != nil { + site, seriesID, readerID, ts, *num, SightingDisagreementLimit); err != nil { return fmt.Errorf("record sighting %s:%s: %w", site, seriesID, err) } return nil @@ -1123,8 +1123,8 @@ const SightingAgreementsToClear = 20 // the Poll confirming the stored value; its opposite is the Poll finding a // lower number, which means the raise was false. // -// A Poll finding a *higher* number is neither — the Site published — and must -// not reach this at all. +// A Poll finding a *higher* number is neither — the Site published — and takes +// ClearSightingAttribution instead. func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agreed bool) error { tx, err := s.db.Begin() if err != nil { @@ -1151,9 +1151,7 @@ func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agr if _, err := tx.Exec(q, args...); err != nil { return fmt.Errorf("record sighting outcome for reader %d: %w", readerID, err) } - if _, err := tx.Exec( - `UPDATE series SET latest_raised_by = NULL WHERE site = $1 AND series_id = $2`, - site, seriesID); err != nil { + if _, err := tx.Exec(clearAttributionSQL, site, seriesID, readerID); err != nil { return fmt.Errorf("clear sighting attribution %s:%s: %w", site, seriesID, err) } if err := tx.Commit(); err != nil { @@ -1161,3 +1159,21 @@ func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agr } return nil } + +// ClearSightingAttribution answers a Sighting without judging it: the Poll +// found a higher number, so the value about to be stored is its own and this +// Reader is no longer answerable for the row. Without it the next Poll's +// agreement would be credited to a Reader who did not earn it. +func (s *Store) ClearSightingAttribution(site, seriesID string, readerID int64) error { + if _, err := s.db.Exec(clearAttributionSQL, site, seriesID, readerID); err != nil { + return fmt.Errorf("clear sighting attribution %s:%s: %w", site, seriesID, err) + } + return nil +} + +// clearAttributionSQL drops the attribution only while it still names the +// Reader being judged: a Sighting landing between the due query's snapshot and +// this write is a fresh, unjudged one and must not be erased by the previous +// one's verdict. +const clearAttributionSQL = `UPDATE series SET latest_raised_by = NULL + WHERE site = $1 AND series_id = $2 AND latest_raised_by = $3` diff --git a/docs/adr/0011-sighting-deferral-trust-model.md b/docs/adr/0011-sighting-deferral-trust-model.md index f1bd70c..189ad01 100644 --- a/docs/adr/0011-sighting-deferral-trust-model.md +++ b/docs/adr/0011-sighting-deferral-trust-model.md @@ -5,9 +5,9 @@ Status: accepted ## Decision -A **Sighting** is what a userscript PUT already carries: the Latest Chapter the -Reader's own browser read off the Series page. It is now allowed to stand in for -a Poll, under one restriction and one ceiling: +A **Sighting** is the Latest Chapter the Reader's own browser read off the +Series page and PUT to the backend. It is now allowed to stand in for a Poll, +under one restriction and one ceiling: - **Solitary Series only.** A Sighting defers the Poll of a Series exactly one Bookmark points at. A Series two Readers share is Polled on schedule no matter @@ -15,8 +15,9 @@ a Poll, under one restriction and one ceiling: - **One rest of standing.** A Sighting postpones Polls for one Rest (`defaultRest`, an hour), not forever: a Series nobody visits again returns to the normal schedule by itself. -- **Six-hour ceiling.** `sightingCeiling = 6 * defaultRest`. However many - Sightings arrive, a Series unpolled for six hours is Polled. +- **Six-rest ceiling.** `sightingCeilingRests = 6`, counted in the Site's own + Rest — six hours everywhere today. However many Sightings arrive, a Series + unpolled that long is Polled. Both live in the due query's HAVING clause (`store.DueForLatestCheck`), beside the Rest cutoff — the same place the schedule has always been decided, so no @@ -31,7 +32,9 @@ Attribution and judgement: publishes against what is stored, so judgement costs no extra request: a lower number contradicts the Sighting (`sighting_disagreements + 1`, both numbers and the Reader logged), the same number confirms it (`sighting_agreements + 1`), a - **higher** number is the Site publishing and means nothing either way. + **higher** number is the Site publishing and means nothing either way — but it + does clear the attribution (`Store.ClearSightingAttribution`), because the + value stored afterwards is the Poll's own and nobody must answer for it. - At `SightingDisagreementLimit` (3) that Reader's Sightings stop deferring anything. They still write the Latest Chapter — the penalty removes a privilege, it does not silence anyone. @@ -40,12 +43,20 @@ Attribution and judgement: - The owner clears marks from the administration page (issue #102, shipped first precisely so a false mark has a remedy the day the mechanism lands). +One client change was required, and only one. Both userscripts stopped short of +PUTting a read whose number had not moved (`applyLatestChapterIfChanged`), so +the case this whole mechanism exists for — visiting a Series with nothing new — +never reached the backend. `reportLatestChapter` now sends it, skipping only the +local write and the re-render. A numberless PUT (favourite toggle, progress from +a chapter page) is not a Sighting and defers nothing: nobody read the Series +page, so there would be nothing to judge later. + ## Why Most of the backend's work was redundant. The userscript reads the Latest -Chapter on every Series page visit and PUTs it; minutes later the Poll Lane -fetches the same page for the same number. Deferring on a report converts a -visit into a Poll saved, which is Lane capacity handed back to Series nobody is +Chapter on every Series page visit; minutes later the Poll Lane fetches the same +page for the same number. Deferring on a report converts a visit into a Poll +saved, which is Lane capacity handed back to Series nobody is reading. The restriction is the whole safety argument, and it is about **blast radius**, @@ -72,14 +83,31 @@ therefore not a strategy, and credit cannot be banked in advance. - **Trusting a Sighting on a shared Series** rejected: it is the only case where one Reader's mistake reaches another Reader's list, and no amount of reputation makes that recoverable within the six-hour window. -- **Cross-Reader agreement, voting, weighting, consensus scoring** rejected: a - single-source report cannot have its confidence evaluated by comparison, and - with the typical two Readers a disagreement identifies nothing. The Poll is - the only oracle in the system, so it is the only judge. +- **Cross-Reader agreement, voting, weighting, consensus scoring** rejected on + evidence: every truth-discovery method estimates source reliability by + comparing sources on the same object, and the standard survey states outright + that an object provided by very few sources cannot have its confidence + evaluated — Li, Gao, Meng, Li, Su, Zhao, Fan, Han, *A Survey on Truth + Discovery*, SIGMOD Record 45(1), 2016 (arXiv:1505.02463), §"Challenges" on + sparse sources. With the two Readers this backend actually has, a + disagreement is a coin flip. The Poll is an authoritative oracle, so it is + the only judge. - **A randomised audit** (Poll a fraction of deferred Series) rejected in favour - of the fixed ceiling: a sampled audit makes the attacker's expected cost a - probability, while a ceiling makes it a certainty, and a certainty is what - makes the solitary-Series rule defensible in one sentence. + of the fixed ceiling. Sampling an oracle against untrusted reports is the + gold-question technique from crowdsourcing quality control — Le, Edmonds, + Hester, Biewald, *Ensuring quality in crowdsourced search relevance + evaluation: the effects of training question distribution*, SIGIR 2010 + Workshop on Crowdsourcing for Search Evaluation, which inserts known answers + sporadically and adjusts each worker's trust from them. The ceiling is the + same idea made deterministic: sampling prices an attack in expectation, a + guaranteed six-hour audit prices it as a certainty, which is what makes the + solitary-Series rule defensible in one sentence. +- **A trust *ratio*** (agreements over judgements, as that same gold-question + scheme uses) rejected for two thresholds: a ratio lets an attacker bank + credit first and spend it on lies later, and it needs the owner watching a + score to act. Three-and-twenty is a threshold both ways — a disagreement + resets the run to zero, so credit cannot be pre-bought, and recovery happens + without the owner in the loop. - **Blocking a marked Reader's writes** rejected: the Latest Chapter they report is still the best available value, and their Sightings must keep being judged or they could never earn the privilege back. @@ -87,10 +115,15 @@ therefore not a strategy, and credit cannot be banked in advance. not the thing that can be wrong. Naming the Reader and logging both numbers is also what distinguishes a broken Site adapter (every Reader of that Site contradicted at once) from one bad actor. -- **Timers or a background reputation job** rejected: deferral is decided from - live facts every round — Bookmark count, sighting timestamp, the Reader's - marks — so a Series that gains a second Bookmark stops deferring at once, with - nothing to invalidate. +- **Timers or a background reputation job** rejected: deferral is recomputed + from live facts every round — Bookmark count and sighting timestamp — so a + Series that gains a second Bookmark stops deferring at once, with nothing to + invalidate. The Reader's marks are the one input read earlier, when the + Sighting is recorded rather than when the round runs: a Reader who crosses + the threshold, or has their marks cleared, changes behaviour from their next + Sighting on, and the standing they already bought lasts out its rest. That is + bounded by one rest and costs one subselect instead of joining `readers` into + the due query on every round. ## Constraints preserved diff --git a/graphify-out/.graphify_labels.json b/graphify-out/.graphify_labels.json index 35bc990..c4e62f3 100644 --- a/graphify-out/.graphify_labels.json +++ b/graphify-out/.graphify_labels.json @@ -40,6 +40,7 @@ "38": "novel-logic.test.js", "39": "UI Critique 2026-07-26A", "40": "UI Critique 2026-07-26B", + "41": "Handler", "42": "pgtest.go", "43": "Issue Tracker & Triage", "44": "Ticket Workflow", @@ -151,6 +152,7 @@ "150": "Reviewer Subagent (opencode)", "151": "Finding Severity Rubric", "152": "Spec Compliance Review", + "154": "ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them", "156": "ResponseWriter", "161": "Why Use samber/oops", "162": "singleflight Cache Stampede Prevention", diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index c117854..ba847a7 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -1,16 +1,16 @@ # Graph Report - mangaBookmark (2026-08-16) ## Corpus Check -- 116 files · ~284,419 words +- 119 files · ~290,953 words - Verdict: corpus is large enough that graph structure adds value. ## Summary -- 1701 nodes · 3463 edges · 219 communities (69 shown, 150 thin omitted) -- Extraction: 91% EXTRACTED · 9% INFERRED · 0% AMBIGUOUS · INFERRED: 326 edges (avg confidence: 0.77) +- 1736 nodes · 3597 edges · 221 communities (70 shown, 151 thin omitted) +- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 361 edges (avg confidence: 0.78) - Token cost: 0 input · 0 output ## Graph Freshness -- Built from commit: `58014eb8` +- Built from commit: `56afb9f2` - Run `git rev-parse HEAD` and compare to check if the graph is stale. - Run `graphify update .` after code changes (no API cost). @@ -55,6 +55,7 @@ - [[_COMMUNITY_novel-logic.test.js|novel-logic.test.js]] - [[_COMMUNITY_UI Critique 2026-07-26A|UI Critique 2026-07-26A]] - [[_COMMUNITY_UI Critique 2026-07-26B|UI Critique 2026-07-26B]] +- [[_COMMUNITY_Handler|Handler]] - [[_COMMUNITY_pgtest.go|pgtest.go]] - [[_COMMUNITY_Issue Tracker & Triage|Issue Tracker & Triage]] - [[_COMMUNITY_Ticket Workflow|Ticket Workflow]] @@ -166,6 +167,7 @@ - [[_COMMUNITY_Reviewer Subagent (opencode)|Reviewer Subagent (opencode)]] - [[_COMMUNITY_Finding Severity Rubric|Finding Severity Rubric]] - [[_COMMUNITY_Spec Compliance Review|Spec Compliance Review]] +- [[_COMMUNITY_ADR-0011 Sightings — a Reader report defers a Poll where being wrong hurts only them|ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them]] - [[_COMMUNITY_ResponseWriter|ResponseWriter]] - [[_COMMUNITY_Why Use samberoops|Why Use samber/oops]] - [[_COMMUNITY_singleflight Cache Stampede Prevention|singleflight Cache Stampede Prevention]] @@ -235,16 +237,16 @@ - [[_COMMUNITY_Userscript CLAUDE guidance|Userscript CLAUDE guidance]] ## God Nodes (most connected - your core abstractions) -1. `testConfig()` - 54 edges -2. `newTestStore()` - 49 edges +1. `newTestStore()` - 62 edges +2. `testConfig()` - 55 edges 3. `newWebTestServer()` - 49 edges 4. `newTestStore()` - 44 edges 5. `e()` - 33 edges -6. `Open()` - 30 edges -7. `Handler` - 29 edges -8. `Store` - 28 edges -9. `ne()` - 28 edges -10. `De()` - 28 edges +6. `Store` - 31 edges +7. `Open()` - 31 edges +8. `newTestPoller()` - 30 edges +9. `Handler` - 29 edges +10. `ne()` - 28 edges ## Surprising Connections (you probably didn't know these) - `el()` --indirect_call--> `c()` [INFERRED] @@ -268,7 +270,7 @@ - **Headless browser infrastructure (sidecar, on-demand, home deployment)** — docs_adr_0005_on_demand_browser_headless_shell, docs_adr_0005_on_demand_browser_cdp, docs_adr_0005_on_demand_browser_on_demand_start, docs_adr_0006_browser_on_the_home_machine_home_machine_rationale [INFERRED 0.85] - **lightnovelworld series-identity investigation and fix** — docs_research_lightnovelworld_chapter_vs_series_slug_issue_77, docs_research_lightnovelworld_chapter_vs_series_slug_unscoped_regex, docs_adr_0008_series_identity_is_discovered_not_derived_discovered_identity [INFERRED 0.85] -## Communities (219 total, 150 thin omitted) +## Communities (221 total, 151 thin omitted) ### Community 0 - "HTMX Library Internals" Cohesion: 0.08 @@ -280,23 +282,23 @@ Nodes (89): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecor ### Community 2 - "Manga Userscript Adapters" Cohesion: 0.06 -Nodes (76): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+68 more) +Nodes (77): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), armDwell() (+69 more) ### Community 3 - "Novel Userscript Adapters" Cohesion: 0.06 -Nodes (79): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+71 more) +Nodes (78): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLnwStaleRowRepair() (+70 more) ### Community 4 - "Series Acquisition Tests" -Cohesion: 0.08 -Nodes (78): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+70 more) +Cohesion: 0.07 +Nodes (103): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+95 more) ### Community 5 - "Bookmarks API Tests" Cohesion: 0.07 -Nodes (70): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+62 more) +Nodes (71): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+63 more) ### Community 7 - "Cover & Acquire Internals" -Cohesion: 0.07 -Nodes (43): Addr, fakeLanes, Context, Store, WaitGroup, defaultCoverResolver(), fetchCoverBytes(), Client (+35 more) +Cohesion: 0.06 +Nodes (43): Addr, fakeLanes, Context, Store, WaitGroup, isInterstitial(), defaultCoverResolver(), fetchCoverBytes() (+35 more) ### Community 8 - "System Architecture Concepts" Cohesion: 0.15 @@ -318,10 +320,6 @@ Nodes (45): Store, T, newTestStore(), readLatestCheckedAt(), readSeries(), secon Cohesion: 0.08 Nodes (32): Handler, Request, ResponseWriter, Store, Healthz(), writeJSON(), Auth(), compressible() (+24 more) -### Community 13 - "Web UI Handlers" -Cohesion: 0.14 -Nodes (18): currentLib(), currentTab(), filterBookmarks(), Client, HandlerFunc, Request, ResponseWriter, Store (+10 more) - ### Community 14 - "Go Error Handling" Cohesion: 0.06 Nodes (33): Creating Errors, Custom Error Types, Custom types that wrap other errors, Decision table: which error strategy to use, Error Creation, Error String Conventions, Errors as Values, `errors.New` — static error messages (+25 more) @@ -368,7 +366,7 @@ Nodes (12): coverResponse(), Request, T, TestCoverFetcherCanonicalisesJpgAlias() ### Community 26 - "Open" Cohesion: 0.05 -Nodes (62): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+54 more) +Nodes (61): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+53 more) ### Community 27 - "Find Skills Guide" Cohesion: 0.14 @@ -387,8 +385,8 @@ Cohesion: 0.40 Nodes (5): Map of pointers for large, frequently updated structs, Memory Layout, Pointer receivers for large structs, Struct field alignment, Zero-size field at end of struct ### Community 31 - "Store" -Cohesion: 0.08 -Nodes (8): coverRelativePath(), coverSourceAddress(), displayChapter(), Store, scanSeries(), TestDisplayChapter(), Bookmark, ReaderSummary +Cohesion: 0.09 +Nodes (5): coverRelativePath(), coverSourceAddress(), Store, scanSeries(), ReaderSummary ### Community 33 - "Go Testing Guide" Cohesion: 0.20 @@ -418,6 +416,10 @@ Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Cohesion: 0.29 Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider +### Community 41 - "Handler" +Cohesion: 0.14 +Nodes (18): currentLib(), currentTab(), filterBookmarks(), Client, HandlerFunc, Request, ResponseWriter, Store (+10 more) + ### Community 42 - "pgtest.go" Cohesion: 0.18 Nodes (12): M, TestMain(), M, TestMain(), M, Main(), start(), URL() (+4 more) @@ -534,6 +536,10 @@ Nodes (3): ADR-0005: On-demand browser sidecar, Constraints, Decision Cohesion: 0.48 Nodes (6): T, TestCreateAndGetSession(), TestDeleteSessionIsPerReader(), TestDeleteSessionRevokes(), TestExpiredSessionIsGone(), TestGetSessionUnknownID() +### Community 154 - "ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them" +Cohesion: 0.33 +Nodes (5): ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them, Constraints preserved, Decision, Tradeoffs and rejections, Why + ### Community 156 - "ResponseWriter" Cohesion: 0.18 Nodes (13): AdminPatterns(), HandlerFunc, Request, ResponseWriter, Time, Handler, readerPathID(), since() (+5 more) @@ -543,24 +549,24 @@ Cohesion: 0.50 Nodes (3): Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust), Second script: `novel-bookmark.user.js`, Userscript structure (single IIFE, `manga-bookmark.user.js`) ## Knowledge Gaps -- **520 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+515 more) +- **524 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+519 more) These have ≤1 connection - possible missing edges or undocumented components. -- **150 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. +- **151 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ - **Why does `Open()` connect `CDP Browser Client` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `pgtest.go`, `Store Tests`, `Store`?** + _High betweenness centrality (0.060) - this node is a cross-community bridge._ +- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Handler`, `Session Middleware`, `CDP Browser Client`, `ResponseWriter`?** _High betweenness centrality (0.050) - this node is a cross-community bridge._ -- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Session Middleware`, `Web UI Handlers`, `CDP Browser Client`, `ResponseWriter`?** - _High betweenness centrality (0.048) - this node is a cross-community bridge._ - **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`, `ResponseWriter`?** - _High betweenness centrality (0.031) - this node is a cross-community bridge._ + _High betweenness centrality (0.030) - this node is a cross-community bridge._ +- **Are the 25 inferred relationships involving `newTestStore()` (e.g. with `TestAcquireDoesNotBlockTheWrite()` and `TestAcquireFailureLeavesTheBookmarkIntact()`) actually correct?** + _`newTestStore()` has 25 INFERRED edges - model-reasoned connections that need verification._ - **Are the 48 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?** _`testConfig()` has 48 INFERRED edges - model-reasoned connections that need verification._ -- **Are the 12 inferred relationships involving `newTestStore()` (e.g. with `TestAcquireDoesNotBlockTheWrite()` and `TestAcquireFailureLeavesTheBookmarkIntact()`) actually correct?** - _`newTestStore()` has 12 INFERRED edges - model-reasoned connections that need verification._ - **Are the 8 inferred relationships involving `newWebTestServer()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverRejectsUnknownAddress()`) actually correct?** _`newWebTestServer()` has 8 INFERRED edges - model-reasoned connections that need verification._ - **What connects `bookmarkmanager/backend`, `ctxKey`, `loginView` to the rest of the system?** - _552 weakly-connected nodes found - possible documentation gaps or missing edges._ \ No newline at end of file + _556 weakly-connected nodes found - possible documentation gaps or missing edges._ \ No newline at end of file diff --git a/graphify-out/graph.html b/graphify-out/graph.html index ceb0d3e..c77ae56 100644 --- a/graphify-out/graph.html +++ b/graphify-out/graph.html @@ -63,12 +63,12 @@
-
1701 nodes · 3463 edges · 219 communities
+
1736 nodes · 3597 edges · 221 communities