mcpbeat Sign in

Self Normalised Axes Need A Second Shape Agent Skill

Build a radar/fingerprint chart whose axes are normalised 0..1 from your own data — no external corpus, one guarded denominator per axis, and a second polygon (the previous period) because a lone shape on self-normalised axes says nothing. Use when a "usage personality" or "year in review" chart needs a reference it cannot get, when a thin period leaves one axis at a guarded zero beside four real readings, or when one axis goes NaN and the whole polygon disappears.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill self-normalised-axes-need-a-second-shape

The instruction itself

3 sections, as written by the author

Self-normalised axes need a second shape

Five readings of *behaviour*, each bounded 0..1 by construction, computed from one table of events.

The appeal is that no global corpus is needed — the reference implementations of this chart compare

each axis against an average across every user of a service, and a local-only app has no such

average. The cost is that a single polygon is then uninterpretable, which the second shape fixes.

// adapted: "entity" is whatever contributes to an event (a creator, an author, a correspondent)
private fun fingerprintOf(
    dailyCounts: List<Int>,     // events per active day
    entityEvents: List<Int>,    // events per entity, DESCENDING
    newEntities: Int,
    distinctEntities: Int,
    distinctItems: Int,
    events: Int,
): Fingerprint {
    val consistency =                                     // 1 − coefficient of variation
        if (dailyCounts.size < 2) 0f else {
            val mean = dailyCounts.average()
            if (mean <= 0.0) 0f else {
                val variance = dailyCounts.sumOf { (it - mean) * (it - mean) } / dailyCounts.size
                (1.0 - sqrt(variance) / mean).coerceIn(0.0, 1.0).toFloat()
            }
        }
    val total = entityEvents.sum()
    val diversity =                                       // normalised Shannon entropy
        if (total <= 0 || entityEvents.size < 2) 0f else {
            val h = entityEvents.sumOf { c -> (c.toDouble() / total).let { if (it > 0) -it * ln(it) else 0.0 } }
            (h / ln(entityEvents.size.toDouble())).coerceIn(0.0, 1.0).toFloat()
        }
    return Fingerprint(
        consistency = consistency,
        discovery = if (distinctEntities <= 0) 0f else (newEntities.toFloat() / distinctEntities).coerceIn(0f, 1f),
        diversity = diversity,
        concentration = if (total <= 0) 0f else (entityEvents.take(5).sum().toFloat() / total).coerceIn(0f, 1f),
        replay = if (events <= 0) 0f else (1f - distinctItems.toFloat() / events).coerceIn(0f, 1f),
    )
}

Traps

A lone polygon on self-normalised axes carries no information. "Consistency 0.62" is neither high

nor low: the scale was built from this data, so there is nothing outside it to be high or low

against. Draw the previous period's polygon under the current one — stroked, not filled, so the

filled current shape reads as the foreground — and the reader gets a direction even without a

vocabulary for the units. When there is no previous period, omit its legend entry too; a legend

that names a shape the canvas does not contain sends the reader hunting for it.

Every axis here divides, and every divisor can be zero on a thin period. Four different

denominators (mean, total, distinctEntities, events) each need their own guard at their own

site — a single "enough data?" check at the top is the wrong shape, because the axes go empty at

different thresholds. The subtle one is ln(n): it is zero at n = 1, so the entityEvents.size < 2

test is a divide-by-zero guard wearing the costume of a sample-size guard. Delete it as redundant

and a user with exactly one entity gets 0.0 / 0.0.

coerceIn is not a zero-guard. Kotlin's clamp is two comparisons, and every comparison against

NaN is false, so Double.NaN.coerceIn(0.0, 1.0) returns NaN unchanged. The value then reaches the

path builder, which multiplies it into a coordinate — and a path with one NaN vertex does not draw a

wrong shape, it draws no shape. One unguarded division silently deletes the other four axes with

it. Guard before the ratio; clamp only to fix the sign and the tail.

take(5) means "the top five" only because a query said so. The concentration axis is the share

landing on the five biggest entities, but nothing in the Kotlin enforces that ordering — it comes

from ORDER BY count DESC in the query that produced the list. Reorder that query for some unrelated

reason (or route the list through a Map, or groupBy) and the axis silently becomes "the share

landing on five arbitrary entities", still bounded 0..1, still plausible on screen.

Normalise for size, not by size. Raw entropy grows with the number of entities, so a bigger

library would score higher on diversity forever. Dividing by ln(n) — the entropy of a perfectly

even spread over that same n — turns the axis into "how even is this, for its size". Any axis that

does not do this is really measuring volume, and volume already has its own number on the screen.

Axes stitched from separate queries can contradict each other. replay is 1 − distinctItems / events

where events is the row count from one scan and distinctItems is a COUNT(DISTINCT …) from

another, taken moments later and outside any transaction. A write landing between them can push

distinctItems above events and the axis negative. The clamp is what stops that reaching the

canvas — so it is load-bearing, not cosmetic, and a reviewer who "simplifies" it away is removing a

guard. Either take both numbers in one transaction or keep the clamp and say why.

Write the bound next to the formula, because "bounded 0..1 by construction" is a claim per axis.

discovery = newEntities / distinctEntities is bounded only because every entity counted as new

also appears in the window — that is a property of the two queries, not of the arithmetic. Let "new"

later be computed over a different span than "distinct" and the axis exceeds 1, where the clamp

hides it at exactly 100% forever.

The share axes need an unbounded query; the list beside them needs a capped one. Concentration

and diversity are shares of the whole, so feeding them the same LIMIT 100 list that renders the

top-five panel inflates both — the cut tail is missing from the denominator. Same window, same

grouping, two queries with different bounds: unbounded-for-shares-capped-for-lists.

Zero is a real reading; "not enough data to say" is not. Every guard above returns 0f, and

because they trip at different thresholds those zeros arrive one axis at a time. A period with

one active day trips only dailyCounts.size < 2, so the shape reads consistency 0%, discovery 100%,

diversity 97%, concentration 100%, replay 0% — lopsided, not collapsed, and the two 0%s mean

opposite things: consistency is the guard declining to answer, replay is a real measurement that

nothing was repeated. Nothing on the canvas separates them, so the whole shape reads as measured. If

the distinction matters, carry it in the type rather than in the value (unknown-not-a-valid-score),

or suppress the chart under a minimum sample and say why it is missing.

Print the numbers beside the shape. They are the only thing that survives when the second polygon

is absent, and they are what makes the chart checkable at all — a polygon cannot be read to two

significant figures, so without them nobody can ever tell you an axis is wrong.

The two snapshots the comparison needs must be fetched as a matched pair, not as independent

streams — see one-snapshot-per-period-not-many-flows. Absence of the reference is expressed the

same way absence of a change figure is: see delta-absent-not-infinite.

Verifying it

Run these from the repository root. They are read-only.

  • Every clamped ratio in the codebase, so you can check each divisor is guarded *before* the

division rather than clamped after it. Expect around a dozen hits; the fingerprint block is five

consecutive ones:

   grep -rn --include='*.kt' "coerceIn(0" . | grep -v '/build/' | grep -E "/ [a-zA-Z(]"
  • The hidden ordering dependency. Every take(5) that feeds a share must be traceable to a

descending sort; the two commands should be read together:

   grep -rn --include='*.kt' "take(5)" . | grep -v '/build/'
   grep -rn --include='*.kt' "ORDER BY playCount" . | grep -v '/build/'
  • By hand, on a fresh install: step to a period with exactly one active day. Only consistency

should read 0%, the other four should read real values, and nothing should mark that 0% as "no

reading". Then step to a period with no events and confirm the section renders no chart at all

rather than a collapsed shape — with the legend having dropped the missing polygon's entry too.

How to use it

Copy the folder

Take maxrave-dev/self-normalised-axes-need-a-second-shape from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.