Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt
T
irisandClaude Opus 5 edc39c7371 Thin the app's comments
The same pass the server had, on the Kotlin side: comments restating what
the code says are gone, and the ones recording a measurement, a constraint
or an incident are kept but cut to a few lines each. 6540 comment lines to
5674, and 920 lines off the app.

Two doc comments had drifted onto the item above the one they describe --
`contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s
equivalent on the server was fixed in the previous commit. Each is back on
its own item, which is the only non-comment line this diff moves.

The comments are reflowed to the column limit at their own indentation:
several were written wide, and ktfmt re-wrapped them into lines holding a
single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the
result, which is the check.

Left alone deliberately: this codebase's remaining comment density is high
because the comments carry things the code cannot say -- what a null means,
what a number was measured against, which bug a guard exists for. Of the
238 one-line doc comments in the app, five were pure restatement of the
name and were removed; the rest each say something the signature does not.

ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass;
cargo test (127), clippy --all-targets and fmt still clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:20:16 -04:00

161 lines
6.5 KiB
Kotlin

package com.example.aiapp
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
* Counters and timers for the work the transcript does, for the readout behind the debug button.
*
* Here because the emulator cannot answer the question this is for. Its own scroll sits at the same
* frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost
* is under the floor of what it can measure. Counts do not have that problem: how many times a row
* was composed, or a reply parsed, is the same number on any machine, and it is the number that
* says whether the work is proportional to what is on screen or to everything ever loaded.
*
* Always on rather than behind a build flag. What is measured is an atomic increment on paths that
* already allocate lists and parse markdown, and a counter that is only compiled into the build
* nobody is holding when it is slow is not an instrument.
*/
object DebugStats {
private val counts = ConcurrentHashMap<String, AtomicLong>()
private val nanos = ConcurrentHashMap<String, AtomicLong>()
private val worst = ConcurrentHashMap<String, AtomicLong>()
private fun at(map: ConcurrentHashMap<String, AtomicLong>, name: String) =
map.computeIfAbsent(name) { AtomicLong() }
fun count(name: String, by: Long = 1) {
at(counts, name).addAndGet(by)
}
/** Keeps [name] at the largest value it has been given, for a high-water mark. */
fun atLeast(name: String, value: Long) {
val slot = at(counts, name)
while (true) {
val had = slot.get()
if (value <= had || slot.compareAndSet(had, value)) break
}
}
/** Records one occurrence of [name] that took [elapsed] nanoseconds. */
fun record(name: String, elapsed: Long) {
count(name)
at(nanos, name).addAndGet(elapsed)
val slot = at(worst, name)
while (true) {
val had = slot.get()
if (elapsed <= had || slot.compareAndSet(had, elapsed)) break
}
}
fun <T> timed(name: String, body: () -> T): T {
val started = System.nanoTime()
try {
return body()
} finally {
record(name, System.nanoTime() - started)
}
}
fun reset() {
counts.clear()
nanos.clear()
worst.clear()
}
/** One line per counter: how many, how long in total, and the worst single one. */
fun lines(): List<String> =
counts.keys.sorted().map { name ->
val n = counts[name]?.get() ?: 0
val total = nanos[name]?.get() ?: 0
if (total == 0L) " $name: $n"
else
" $name: $n, ${ms(total)}ms total, ${ms(total / n.coerceAtLeast(1))}ms mean," +
" ${ms(worst[name]?.get() ?: 0)}ms worst"
}
/** How long everything named [name] took in total, or zero if it never happened. */
fun nanosOf(name: String): Long = nanos[name]?.get() ?: 0
private fun ms(nanos: Long) = "%.1f".format(nanos / 1_000_000.0)
}
/**
* How much of the frame's draw phase is this app's own work, and how much is not.
*
* The draw phase is where Compose's measurement lands as well as its recording -- the platform
* calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three
* different things is high. The transcript times its own measure, placement and recording, and this
* is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout,
* which grows with how many nodes are alive rather than how many are on screen.
*
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
* are not themselves per-frame, so these are shares of an average frame.
*/
fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
if (frames == 0 || drawNanos == 0L) return emptyList()
val measure = DebugStats.nanosOf("measure: the whole transcript")
val place = DebugStats.nanosOf("place: the whole transcript")
// The rows and blocks record *inside* this one, so adding them too would count them twice.
val record = DebugStats.nanosOf("draw: the whole transcript")
val ours = measure + place + record
val rest = (drawNanos - ours).coerceAtLeast(0)
fun per(n: Long) = "%.2f".format(n / 1_000_000.0 / frames)
return listOf(
" draw phase ${per(drawNanos)}ms per frame, of which:",
" the transcript: ${per(ours)}ms" +
" (measure ${per(measure)}, place ${per(place)}, record ${per(record)})",
" everything else: ${per(rest)}ms" +
" (${if (drawNanos == 0L) "n/a" else "${rest * 100 / drawNanos}%"})",
)
}
/**
* Everything the debug button copies: what the device is, what the transcript is holding, where the
* frames went, and what the app did to produce them.
*
* Written for somebody to paste into a conversation, so it is plain text with the units on every
* number -- a report whose reader has to ask what the columns mean costs another round trip.
*/
fun debugReport(
device: String,
transcript: List<String>,
frames: List<String>,
accounting: List<String>,
crash: String?,
): String = buildString {
appendLine("ai-app render report")
appendLine(device)
appendLine()
// First, because a crash outranks every timing below it and the reader should not have to
// scroll past two screens of counters to find out the app fell over.
if (crash != null) {
appendLine("last crash:")
crash.trimEnd().lines().forEach { appendLine(" $it") }
appendLine()
}
appendLine("transcript:")
transcript.forEach { appendLine(it) }
appendLine()
appendLine("frames:")
frames.forEach { appendLine(it) }
appendLine()
if (accounting.isNotEmpty()) {
appendLine("where the draw phase went:")
accounting.forEach { appendLine(it) }
appendLine()
}
appendLine("work since this was last copied:")
val work = DebugStats.lines()
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
}
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
fun Context.copyToClipboard(label: String, text: String) {
getSystemService<ClipboardManager>()?.setPrimaryClip(ClipData.newPlainText(label, text))
}