Skip to content

feat: add network history and streamline profile audits - #27

Merged
quangshuynh merged 6 commits into
mainfrom
feat/network-history-candidacy-performance
Sep 22, 2026
Merged

quangshuynh merged 6 commits into
mainfrom
feat/network-history-candidacy-performance

Conversation

@quangshuynh

Copy link
Copy Markdown
Owner

Three workstreams, each measured before it was changed.

Network chronology

GitHub exposes no follow timestamp and documents no ordering for the follower and following endpoints. The previous audit (PR #26) established that and left the lists in the API's order. This adds the only chronology GitProfileLens can honestly offer: when it first observed each relationship, in this browser.

network-history.js stores firstObservedAt, lastObservedAt and currentlyPresent per relationship. No field is named for a follow event, and a test scans the shipped source to keep it that way.

The design decision that carries the feature is cohorts. An observation establishes a set, not a sequence: seeing 147 followers at once proves all 147 existed by that moment and proves nothing about their order. Everything first seen in one observation therefore shares one firstObservedAt, so loop iteration order can never masquerade as chronology. The first observation is one baseline cohort with no internal order; each later observation adds at most one new cohort.

  • Lists sort newest cohort first, stably within a cohort, so a cohort keeps GitHub's order.
  • The interface and the Markdown export share one sentence — "Most recently observed first", with the limitation stated — so they cannot disagree.
  • Non-follow-back is derived from the already-ordered Following list and inherits it.
  • Completeness is respected per list: an incomplete retrieval is not merged at all, so it can never mark the accounts it did not reach as gone.
  • A reappearance is recorded as one; firstObservedAt is never rewritten.
  • localStorage, versioned, one read and at most one write per observation, bounded profile count, unknown schema versions discarded rather than guessed at, blocked or full storage degrading to no history rather than to no Network.
  • An explicit two-step reset, and nowhere else. History is never cleared as a side effect of anything.

29 node tests and 5 browser tests cover the semantics. docs/network-ordering.md specifies it, including what it still cannot do.

Portfolio candidacy

The optimizer ranks every Strong candidate ahead of every Worth polishing repository before presentation score is consulted. Breadth carries a score band for exactly this shape of problem, so the partition was measured against the same question.

The partition is unchanged, for reasons the evidence supports rather than for want of looking:

  • Moving candidacy below score, and bounding it at 2, 5 and 10 points, produces identical recommended sets for all 13 corpus profiles and for a live 33-repository profile with 12 Strong candidates.
  • Seven of the nine gates read evidence the score already reads, so a repository that fails one has already paid for it in score. The Strong floor is 87 and the Worth ceiling is 90: the widest inversion presentation evidence can produce is 3 points. Breadth was unbounded because language is orthogonal to score; candidacy is correlated with it, and that correlation is the ceiling.
  • The two gates the score does not read — fork and archive status — already have dedicated ranking stages immediately after candidacy, so there the partition's ordering contributes nothing.

Every gate is still a cliff, sharpest at maintenance where one day either side of 730 flips the label for three points of score. That is recorded rather than patched, because the coupling above caps what the flip can do.

npm run eval:candidacy reproduces all of it; tests/scoring/candidacy-policy.test.js asserts the findings through the diagnostic's own functions, so if the partition ever starts moving a recommendation the question reopens with the failing case in hand.

Performance

npm run eval:performance drives the real page in a real browser and reports counts, not durations.

Fixed:

Problem Before After
GET /users/{login} fetched twice, once for the audit and again in fetchNetwork 4 GitHub requests to open Network; 2 repeated URLs 3 requests; 0 repeated URLs
scoreProfile computed twice per render 2 calls per audit 1
Disclosure rebuilt lists already on screen (1,200 followers) Show 25 more: 50 items; Show all: 1,200; Collapse: 25 25 / 1,150 / 0

Measured and deliberately not changed, with the evidence recorded in docs/scoring.md:

  • Deferring the audit panel (5,797 of 7,415 nodes) was A/B tested: removing the entire render produced no measurable change in time to a usable audit. Element creation is not the bottleneck.
  • The optimizer and Markdown together account for 121 of 7,415 nodes and already run once each.
  • The README GraphQL query's three filename aliases cost nothing — the other two resolved for zero repositories on a real profile — and the browser receives only the 6.5 KB analysis.
  • Relationship pagination still issues one request past the last full page. Completeness is not traded for a request.

Verification

npm run check, npm test (336 pass), npm run eval, npm run eval:pins (no corpus outcome or recommendation changed), npm run eval:candidacy, npm run eval:performance, npm run test:browser, git diff --check, and a real npm start against the live profile: 147 followers, 396 following, 303 not following back, baseline cohort formed, profile fetched once, export and interface in agreement, no page errors.

GitHub exposes no follow timestamp and documents no ordering for the
follower and following endpoints, so the Network lists have had no
chronology to sort by. This records one GitProfileLens can defend: when
each relationship was first observed in this browser.

network-history.js stores, per audited profile and per relationship list,
firstObservedAt / lastObservedAt / currentlyPresent. Nothing is named for
a follow event, and a test scans the shipped source to keep it that way.

The design decision that matters is cohorts. An observation establishes a
set, not a sequence: seeing 131 followers at once proves all 131 existed
by that moment and proves nothing about their order. Everything first seen
in one observation therefore shares one firstObservedAt, so loop order can
never masquerade as chronology.

Completeness is respected per list. An incomplete retrieval is not merged
at all, so it can never mark the accounts it did not reach as gone.

Storage is localStorage: one versioned JSON value, one read and at most one
write per observation, bounded profile count, unknown schema versions
discarded rather than guessed at, blocked or full storage degrading to no
history rather than to no Network.
The optimizer ranks every Strong candidate ahead of every Worth polishing
repository before presentation score is consulted at all, and candidacy
settles 81.9% of pairwise comparisons. Breadth carries a score band for
this exact shape of problem, so the partition was measured against the
same question.

evaluation/candidacy-diagnose.js reports the gates, which gate held each
repository back, every Strong/Worth score inversion, how much displacement
the partition causes, threshold sensitivity, a policy comparison, and ten
constructed cases the corpus does not contain. It first asserts its own
gate table reproduces the production classifier, so it cannot describe a
classifier the product does not have.

The finding is that the partition is unchanged, for reasons the corpus
supports rather than for want of looking:

- Moving candidacy below score, and bounding it at 2, 5 and 10 points,
  produces identical recommended sets for all 13 corpus profiles, and for
  a live 33-repository profile with 12 Strong candidates.
- Seven of the nine gates read evidence the score already reads, so a
  repository that fails one has already paid for it in score. The Strong
  floor is 87 and the Worth ceiling is 90: the widest inversion
  presentation evidence can produce is 3 points. Breadth was unbounded
  because language is orthogonal to score; candidacy is correlated with
  it, and that correlation is the ceiling.
- The two gates the score does not read, fork and archive status, already
  have dedicated ranking stages immediately after candidacy, so for those
  the partition's ordering contributes nothing.

Every gate is still a cliff, sharpest at maintenance where one day either
side of 730 flips the label for three points. That is recorded rather than
patched, because the coupling above caps what the flip can do.

tests/scoring/candidacy-policy.test.js asserts these measurements through
the diagnostic's own functions, so if the partition ever starts moving a
recommendation the question reopens with the failing case in hand.
evaluation/performance-diagnose.js drives the real page in a real browser
against mocked GitHub responses and reports counts rather than durations:
requests issued and repeated, elements created per interaction, derived
computations repeated, DOM nodes per panel, storage writes, and what a
second audit leaves behind. Counts are deterministic; the wall-clock
figures it prints are labelled informational and are not assertions.

Two things it found were real and are fixed:

- GET /users/{login} was requested twice, once for the audit and again
  inside fetchNetwork. The audit now hands its profile over. Opening
  Network on a live-shaped profile drops from 4 GitHub requests to 3 and
  repeated URLs from 2 to 0, which matters against an unauthenticated
  allowance of sixty an hour. An explicit retry still re-reads the
  profile, because retry means look again.
- GitHubAudit.scoreProfile ran twice per render, from the header and from
  the overview, over every repository. Computed once and shared: 2 to 1.

Three things it found are deliberately left alone, with the measurement
recorded in docs/scoring.md so the question is not reopened from scratch:

- Deferring the audit panel, 5,797 of 7,415 nodes at 150 repositories,
  was measured rather than assumed. Removing the entire audit render
  produced no measurable change in time to a usable audit; run-to-run
  noise is larger than the effect. Element creation is not the bottleneck.
- The optimizer and Markdown together account for 121 of 7,415 nodes and
  already run once each. There is nothing worth the invalidation logic.
- The README GraphQL query's three filename aliases cost nothing: on a
  real 33-repository profile the other two resolved for zero
  repositories, and the browser receives only the 6.5 KB analysis.

Relationship pagination still issues one request past the last full page.
A short page is the only evidence a list has ended, and the reported count
can change mid-retrieval, so completeness is not traded for a request.
Every control in the reset group disappears when it is used: the reset
button when the confirmation opens, the confirmation when it is answered,
and the whole group when history is deleted. Each of those left focus on a
hidden element, which drops a keyboard reader back to the document body.

Focus now follows the control that replaced the one that vanished, and
after a deletion lands on the sentence that says what happened, which is a
status region so it is announced rather than only shown.
@vercel

vercel Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
gitprofilelens Ready Ready Preview Sep 22, 2026 7:26am UTC

The previous interval concluded that the Strong / Worth partition needed
no band, and rested that on a claimed bound: a Strong floor of 87, a Worth
ceiling of 90, and a maximum inversion of 3 points. That was not a bound.
It was one hand-built pair from one generator, reported as though it
described the whole classifier, and the live profile it was meant to
explain contradicted it outright by carrying Worth polishing repositories
at 93, 93 and 94.

measureReachableBounds now answers the question by search rather than by
example, enumerating 120,960 repository shapes per population. The real
figures:

  lowest score a Strong can reach                  79
  highest a Worth can reach while failing
    only score-visible gates                       95  (fails readme)
  maximum inversion so caused                      16
  Strong shapes below that ceiling      5,764 of 6,912

So the unbanded rule let a repository presenting at 79 precede one
presenting at 95 because the latter's README carried one core section
fewer than the gate wants, and that covered most of the Strong space
rather than a corner. Sixteen points on a 0-100 scale is not a cap worth
resting an argument on, and the evidence behind it had already been
counted once in the score. This is the same defect breadth was given a
band for in PR #26.

Rule 1 now abstains outside five points, matching the band rule 4 already
uses so the optimizer has one notion of comparable presentation. Fork and
archive status are the two gates the score does not read, and they are
untouched: rules 2 and 3 apply them directly, and the pairwise tally shows
archive picking up the 7 comparisons it should always have settled.

No recommendation moved. The banded rule, the unbanded rule, removing
candidacy from the ordering entirely, and bands of 2, 5 and 10 all produce
identical sets on all 13 corpus profiles and on the live 33-repository
profile, whose recommended set still contains none of the three Worth
polishing repositories that prompted the question. Eligibility is
untouched; this is an ordering bound, not a way to move a set.

What did change is which rule can be pointed at: candidacy settled 81.9%
of pairwise comparisons and now settles 2.3%, while presentation score
rises from 12.3% to 90.4%.
Two problems, one measured and one found while measuring it.

The documentation claimed the bounded profile count kept the snapshot
"well inside a typical 5 MB origin quota". That was asserted, not
measured, and it is wrong. Serializing the worst case gives:

  one live-sized profile (147 + 396)        543 relationships     ~84 KB
  one profile at the pagination caps     20,000 relationships    ~3.1 MB
  twenty profiles at the caps           400,000 relationships     ~59 MB

About 154 bytes per relationship, and browsers commonly charge
localStorage per UTF-16 code unit, so roughly double against a quota. A
single profile at the caps exceeds a 5 MB budget on its own. There is no
portable quota to design against, so the documentation now states the
measured figures and stops claiming the worst case fits. Ordinary use is
far inside any plausible quota: twenty live-sized profiles come to about
1.7 MB.

Verifying the failure path then turned up a real bug. write() adopted the
new snapshot into the in-memory cache *before* attempting setItem, so a
refused write left the session ordering by a cohort that existed nowhere
on disk, and silently reverting on the next reload. A feature whose whole
purpose is to avoid claiming more than the evidence supports cannot also
display an order its own storage rejected.

The snapshot is now adopted only once the write succeeds, and a refused
write reports the history that is actually stored. A refused write is also
retried with only the profile being observed, since a single large network
can exceed the quota alone and the reader is looking at one profile; if
even that is refused nothing is written and prior history stands. The
Network tab says which of these happened rather than letting the
observation count quietly stop advancing.

A browser test drives a real QuotaExceededError through the real Storage
interface and asserts the retrieval, the derived list and the export are
all unaffected, nothing is stored, and the lists fall back to GitHub's
order with the interface saying so.
@quangshuynh

Copy link
Copy Markdown
Owner Author

Two claims in the summary were wrong. Both are corrected, and one hid a bug.

1. "Strong floor 87, Worth ceiling 90, max inversion 3" was not a bound

It was one hand-built pair from one generator, reported as though it described the classifier. The live profile contradicted it outright — it carries Worth polishing repositories at 93, 93 and 94.

measureReachableBounds now answers the question by search, enumerating 120,960 repository shapes per population:

Quantity Claimed Actual
Lowest score a Strong candidate can reach 87 79
Highest a Worth polishing can reach failing only score-visible gates 90 95 (fails the README gate)
Maximum inversion so caused 3 16
Strong shapes below that ceiling — 5,764 of 6,912
Highest Worth with unreported fork status / archived — 100 / 98 (21 and 19-point inversions)

So the unbanded rule let a repository presenting at 79 precede one presenting at 95 because the latter's README carried one core section fewer — across most of the Strong space, not a corner.

The no-change conclusion did depend on the false bound, so I revisited it. 16 points on a 0–100 scale is not a cap worth resting an argument on, and the evidence behind it had already been counted once in the score. This is the same defect breadth was given a band for in PR #26. Rule 1 now abstains outside five points, matching the band rule 4 already uses.

Fork and archive status are the two gates the score does not read, and they are untouched — rules 2 and 3 apply them directly. The pairwise tally confirms it: archive now settles the 7 comparisons it should always have settled.

No recommendation moved. Banded, unbanded, candidacy removed from ordering entirely, and bands of 2/5/10 all produce identical sets on all 13 corpus profiles and on the live 33-repository profile — whose recommended set still contains none of the three Worth polishing repositories that prompted the question. This is an ordering bound, not a way to make current pins win. Eligibility is untouched.

What did change is which rule can be pointed at: candidacy settled 81.9% of pairwise comparisons, now 2.3%; presentation score rises from 12.3% to 90.4%. npm run eval:pins reports no corpus recommendation changed.

2. The storage-size claim was asserted, not measured — and wrong

Serializing the worst case:

Case Relationships Serialized
One live-sized profile (147 + 396) 543 ~84 KB
One profile at the pagination caps 20,000 ~3.1 MB
20 profiles at the caps 400,000 ~59 MB

~154 bytes per relationship, and browsers commonly charge localStorage per UTF-16 code unit, so roughly double against a quota. A single profile at the caps exceeds a 5 MB budget on its own. There is no portable quota to design against, so the docs now state the measured figures and stop claiming the worst case fits. Ordinary use is far inside any plausible quota — twenty live-sized profiles come to ~1.7 MB — so storage is corrected, not redesigned.

Verifying the failure path found a real bug. write() adopted the new snapshot into the in-memory cache before attempting setItem, so a refused write left the session ordering by a cohort that existed nowhere on disk and silently reverting on reload:

ON DISK : alice,bob          (1 cohort)
SESSION : alice,bob,carol    (2 cohorts)  ← ordered carol above the baseline

A feature whose purpose is to avoid claiming more than the evidence supports cannot display an order its own storage rejected. Now: the snapshot is adopted only once the write succeeds; a refused write reports what is actually stored; the retry keeps the profile in view when the full snapshot will not fit; prior history survives intact (a failed setItem does not modify the existing entry); and the interface says which happened rather than letting the observation count quietly stop advancing.

A browser test drives a real QuotaExceededError through the real Storage interface and asserts the retrieval, derived list and export are unaffected, nothing is stored, and the lists fall back to GitHub's order with the interface saying so.

Verification

npm run check, npm test (342 pass), npm run eval and npm run eval:pins (no corpus outcome or recommendation changed), npm run eval:candidacy (gate table reproduces the classifier; production matches band 5), npm run eval:performance, npm run test:browser (39 pass), git diff --check, and a real npm start against the live profile — 151 followers, 396 following, 299 not following back, profile fetched once, export and interface in agreement, no page errors.

@quangshuynh
quangshuynh merged commit d0b3baa into main Sep 22, 2026
4 checks passed
@quangshuynh
quangshuynh deleted the feat/network-history-candidacy-performance branch September 22, 2026 07:27

This branch was successfully deployed

1 active deployment
Preview — 5aeeca77 Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant