Studio: count fork clones per message and drop future streaks

- A pre-fork message can be pruned from its original thread while the
  clone stays in the fork. The suppression only checked that the source
  thread existed, so that message's tokens and activity vanished. Clones
  are now matched to the row they came from, and one fork per source is
  elected to stand in whenever the original is gone, whether the whole
  thread or just that message went.
- Future-dated history was filtered out of the current streak but still
  padded the longest streak and could be reported as the last active
  day. It is dropped before any streak field is computed.
- Cumulative mode labelled its tooltip "week of" while showing the
  running total. It now reports that week's tokens; the bar height still
  uses the running total.
- The activity grid and weekday axis formatted dates with the browser
  language rather than the language chosen in Settings, and the memos
  could not react to a change. They follow the app locale now.

The fork fixtures were also corrected: fork_chat_thread copies created_at
verbatim, so a clone carries the original's timestamp, which the previous
fixtures did not model.
This commit is contained in:
Unsloth 2026-07-29 01:06:06 -07:00
commit 485684db5f
4 changed files with 138 additions and 42 deletions

View file

@ -109,7 +109,12 @@ def _streaks(days: set[date], today: date) -> dict[str, Any]:
The current streak survives a day that has not been used yet: a streak that
ended yesterday is still "live" until today is over.
Imported history or a skewed client clock can date rows in the future.
Those are dropped up front so they cannot pad the longest streak or be
reported as the last active day either.
"""
days = {day for day in days if day <= today}
if not days:
return {"current": 0, "longest": 0, "lastActiveDay": None}
@ -122,9 +127,7 @@ def _streaks(days: set[date], today: date) -> dict[str, Any]:
last = ordered[-1]
current_streak = 0
# A future-dated row (client clock ahead) is not a live streak: the last
# active day still has to be today or yesterday.
if timedelta(0) <= today - last <= timedelta(days = 1):
if today - last <= timedelta(days = 1):
current_streak = 1
cursor = last
while cursor - timedelta(days = 1) in days:
@ -186,14 +189,15 @@ class _MessageFold:
bucket["threads"].add(thread_id)
def _orphan_fork_keepers(conn) -> set[str]:
"""Forks whose source is gone and that should still count their copies.
def _fork_keepers(conn) -> set[str]:
"""One fork per source, elected to stand in for originals that are gone.
``forked_from_thread_id`` is not a foreign key, so deleting a source leaves
its forks holding the only copies of that ancestry. Enabling all of them
would multiply the usage by the number of siblings, so exactly one per dead
source is kept: the one carrying the most copied rows, which is the fork
that branched latest and therefore holds the longest ancestry.
A clone is normally ignored because the original is counted instead. When
the original is not there any more, whether its thread was deleted or just
that message was pruned, the clones become the only record. Letting every
sibling count them would multiply the usage, so exactly one fork per source
is allowed to: the one holding the most copied rows, which is the fork that
branched latest and therefore carries the longest ancestry.
"""
rows = conn.execute(
"""
@ -202,7 +206,6 @@ def _orphan_fork_keepers(conn) -> set[str]:
WHERE m.thread_id = t.id AND m.created_at < t.created_at) AS copied
FROM chat_threads t
WHERE t.forked_from_thread_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM chat_threads s WHERE s.id = t.forked_from_thread_id)
"""
).fetchall()
@ -215,21 +218,36 @@ def _orphan_fork_keepers(conn) -> set[str]:
return {thread_id for _, thread_id in best.values()}
def _surviving_original_keys(conn) -> set[tuple[str, int, str]]:
"""Identity of every message still living in a thread that has been forked.
Clones get fresh ids, so there is nothing to join on. Within one thread the
timestamp and role are enough to recognise the row a clone was taken from.
"""
rows = conn.execute(
"""
SELECT m.thread_id, m.created_at, m.role
FROM chat_messages m
WHERE m.thread_id IN (
SELECT DISTINCT forked_from_thread_id FROM chat_threads
WHERE forked_from_thread_id IS NOT NULL
)
"""
)
return {(row["thread_id"], _as_int(row["created_at"]), row["role"]) for row in rows}
def _fold_messages(conn, zone) -> _MessageFold:
fold = _MessageFold()
keepers = _orphan_fork_keepers(conn)
keepers = _fork_keepers(conn)
surviving = _surviving_original_keys(conn)
rows = conn.execute(
"""
SELECT m.thread_id, m.role, m.metadata_json, m.attachments_json, m.created_at,
t.title, t.model_id, t.model_type, t.pair_id,
t.created_at AS thread_created_at, t.forked_from_thread_id,
src.id AS fork_source_id
t.created_at AS thread_created_at, t.forked_from_thread_id
FROM chat_messages m
LEFT JOIN chat_threads t ON t.id = m.thread_id
-- forked_from_thread_id is not a foreign key, so it outlives the source.
-- Joining to the real row means the copies are only suppressed while
-- the originals are still there to be counted.
LEFT JOIN chat_threads src ON src.id = t.forked_from_thread_id
ORDER BY m.thread_id, m.created_at
"""
)
@ -274,16 +292,14 @@ def _fold_messages(conn, zone) -> _MessageFold:
fold.threads.add(conversation_id)
# Forking clones the whole ancestry into the new thread, keeping each
# copy's original timestamp. Counting those again would double every
# metric for the branched-from conversation, so skip anything older
# than the fork itself. Once the source is gone one fork is elected to
# keep its copies, since they are then the only record left.
if (
row["forked_from_thread_id"]
and created_at < _as_int(row["thread_created_at"])
and (row["fork_source_id"] or thread_id not in keepers)
):
continue
# copy's original timestamp. Skip a clone while the row it was taken
# from is still there to be counted; once that original is gone, only
# the elected fork stands in for it, so nothing is lost or doubled.
source_id = row["forked_from_thread_id"]
if source_id and created_at < _as_int(row["thread_created_at"]):
original = (source_id, created_at, row["role"])
if original in surviving or thread_id not in keepers:
continue
fold.messages += 1
thread_messages += 1

View file

@ -23,6 +23,11 @@ def stats_db(tmp_path, monkeypatch):
invalidate_profile_stats_cache()
# _seed_thread writes the assistant reply this far after the user turn, and
# fork_chat_thread copies created_at verbatim, so clones must reuse it.
REPLY_DELAY = timedelta(seconds = 10)
def _ms(when: datetime) -> int:
return int(when.timestamp() * 1000)
@ -55,7 +60,7 @@ def _seed_thread(conn, thread_id: str, model_id: str, turns: list[tuple[datetime
"assistant",
json.dumps([{"type": "text", "text": "hello"}]),
json.dumps(metadata),
_ms(when + timedelta(seconds = 10)),
_ms(when + REPLY_DELAY),
),
)
@ -309,7 +314,7 @@ def test_forked_threads_do_not_double_count_copied_history(stats_db):
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('fork-a0', 'fork', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2))),
(json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)),
)
conn.commit()
finally:
@ -418,7 +423,7 @@ def test_forks_count_as_chats_before_their_first_new_turn(stats_db):
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('branch-a0', 'branch', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2))),
(json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)),
)
conn.commit()
finally:
@ -537,7 +542,7 @@ def test_deleting_the_source_thread_keeps_the_forks_copies(stats_db):
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('kept-a0', 'kept', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2))),
(json.dumps(_metadata(100, 50)), _ms(now - timedelta(hours = 2) + REPLY_DELAY)),
)
conn.commit()
finally:
@ -577,7 +582,12 @@ def test_sibling_forks_do_not_multiply_a_deleted_source(stats_db):
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, "
"metadata_json, created_at) VALUES (?, ?, 'assistant', '[]', ?, ?)",
(f"{fork_id}-a0", fork_id, json.dumps(_metadata(100, 50)), _ms(older)),
(
f"{fork_id}-a0",
fork_id,
json.dumps(_metadata(100, 50)),
_ms(older + REPLY_DELAY),
),
)
conn.commit()
finally:
@ -600,6 +610,67 @@ def test_sibling_forks_do_not_multiply_a_deleted_source(stats_db):
assert stats["totals"]["messages"] == 1
def test_deleting_an_original_message_keeps_the_forks_clone(stats_db):
"""Pruning one pre-fork message leaves the clone as the only copy."""
now = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
older = now - timedelta(hours = 3)
conn = studio_db.get_connection()
try:
_seed_thread(conn, "orig", "m", [(older, _metadata(100, 50))])
conn.execute(
"INSERT INTO chat_threads (id, title, model_type, model_id, created_at, "
"updated_at, forked_from_thread_id, forked_from_message_id) "
"VALUES ('branch', 'fork', 'base', 'm', ?, ?, 'orig', 'orig-a0')",
(_ms(now), _ms(now)),
)
# The clone keeps the original's timestamp and role.
conn.execute(
"INSERT INTO chat_messages (id, thread_id, role, content_json, metadata_json, "
"created_at) VALUES ('branch-a0', 'branch', 'assistant', '[]', ?, ?)",
(json.dumps(_metadata(100, 50)), _ms(older + timedelta(seconds = 10))),
)
conn.commit()
finally:
conn.close()
# While the original is there the clone is ignored.
assert compute_profile_stats(days = 7)["totals"]["totalTokens"] == 150
conn = studio_db.get_connection()
try:
conn.execute("DELETE FROM chat_messages WHERE id = 'orig-a0'")
conn.commit()
finally:
conn.close()
invalidate_profile_stats_cache()
stats = compute_profile_stats(days = 7)
# The thread survives but that message does not, so the clone stands in.
assert stats["totals"]["totalTokens"] == 150
assert stats["totals"]["assistantMessages"] == 1
def test_future_history_cannot_pad_the_longest_streak(stats_db):
"""Only the current streak was guarded; longest and lastActiveDay were not."""
base = datetime.now().replace(hour = 12, minute = 0, second = 0, microsecond = 0)
conn = studio_db.get_connection()
try:
_seed_thread(
conn,
"skew",
"m",
[(base + timedelta(days = day), _metadata(10, 10)) for day in (3, 4, 5, 6)],
)
conn.commit()
finally:
conn.close()
streak = compute_profile_stats(days = 366)["streak"]
assert streak == {"current": 0, "longest": 0, "lastActiveDay": None}
def test_comparison_panes_count_as_one_chat(stats_db):
"""Compare mode stores a thread per pane; the sidebar shows one chat."""
now = datetime.now()

View file

@ -7,7 +7,7 @@ import {
ChartTooltipContent,
} from "@/components/ui/chart";
import type { ChartConfig } from "@/components/ui/chart";
import { useT } from "@/i18n";
import { useLocale, useT } from "@/i18n";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import type { ProfileStats } from "../../api/profile-stats";
@ -92,15 +92,18 @@ export function HourRhythmCard({ stats }: { stats: ProfileStats }) {
/** Weekday distribution, Monday-first to match the activity grid columns. */
export function WeekdayRhythmCard({ stats }: { stats: ProfileStats }) {
const t = useT();
// The app language, so the axis matches the rest of the panel when the user
// has picked a language that differs from the browser's.
const locale = useLocale();
const names = useMemo(() => {
const formatter = new Intl.DateTimeFormat(navigator.language, {
const formatter = new Intl.DateTimeFormat(locale, {
weekday: "short",
});
// 2024-01-01 was a Monday, so this walks Mon..Sun in the user's locale.
return Array.from({ length: 7 }, (_, index) =>
formatter.format(new Date(2024, 0, 1 + index)),
);
}, []);
}, [locale]);
const data = useMemo(
() =>

View file

@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useT } from "@/i18n";
import { useLocale, useT } from "@/i18n";
import { cn } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ProfileStatsDay } from "../../api/profile-stats";
@ -214,10 +214,12 @@ function BarColumn({
const t = useT();
const summary = columnSummary(column);
const height = barHeight(summary.value, peak);
// The bar is scaled by summary.value, which in cumulative mode is the
// running total. The tooltip says "week of", so it reports that week.
const title = summary.firstDay
? t("settings.profile.stats.weekTooltip", {
date: dateFormatter.format(parseDayKey(summary.firstDay)),
tokens: formatFullNumber(summary.value),
tokens: formatFullNumber(summary.tokens),
})
: "";
@ -246,9 +248,13 @@ export function TokenActivityCard({ daily }: { daily: ProfileStatsDay[] }) {
() => buildColumns(daily, values, columns),
[daily, values, columns],
);
// The app language, not the browser's: those differ whenever the user picks
// a language in Settings, and it has to be a dependency so switching while
// the panel is open rebuilds the formatters.
const locale = useLocale();
const monthLabels = useMemo(
() => buildMonthLabels(grid, navigator.language),
[grid],
() => buildMonthLabels(grid, locale),
[grid, locale],
);
// Daily scales against the busiest day, the bar modes against the busiest
// column, so a full-height bar always means the peak week.
@ -281,12 +287,12 @@ export function TokenActivityCard({ daily }: { daily: ProfileStatsDay[] }) {
const dateFormatter = useMemo(
() =>
new Intl.DateTimeFormat(navigator.language, {
new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
year: "numeric",
}),
[],
[locale],
);
return (