@heyramzi/social 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,34 @@
1
1
  # @heyramzi/social
2
2
 
3
- The heuristics for scripts and posts, written once. CutKit's script check and UpSys's Jev loop
4
- are different apps with their own copy, weights and contracts, and both import their rules from
5
- here so a rule changes in one place.
3
+ Content evaluation, written once. CutKit's script check and UpSys's Jev loop are different apps
4
+ with their own weights and contracts, and both import what's the same from here, so a rule
5
+ changes in one place.
6
6
 
7
7
  | Entry | What it holds | Read by |
8
8
  | --- | --- | --- |
9
9
  | `./story` | Checks with no model: `openerFault` (greeting, hidden point, scene first), `hedgeScan`, `flatRun`, `HOOK_MAX_WORDS` | CutKit's offline lint, `./shorts` |
10
- | `./questions` | `storyQuestions({ field, piece, reader })`: the story questions a judge model answers, in the surface's own words | CutKit's check, Jev's `short`, `opening`, `post` and `x` surfaces |
10
+ | `./questions` | `storyQuestions({ field, piece, reader })`: the story questions a judge model answers, in the surface's own words | CutKit's check, Jev's `opening`, `linkedin`, `x` and `reddit` surfaces |
11
11
  | `./hooks` | One regex per hook-library shape, and the banned shape | `./shorts` |
12
12
  | `./shorts` | `matchHook` and `auditShorts`, the skeleton of a Short | `heyramzi-slop --register=shorts` in `@heyramzi/lint` |
13
+ | `./jev` | The wire shape of a question (`questionBody`), the `Answer` type, and `askJev`: one call that takes the key, model, state and questions, and returns the answers | CutKit's check, UpSys's `tools/social` client |
14
+ | `./grade` | A trained model plus Jev's answers to a grade: `gradeAnswers` (grade 0 to 10, confidence, the three weakest answers with their fixes, the nearest winners, the pass gate), `encode`, `predict`, `rankOn`, `letterGrade`, `PASS_GRADE` | UpSys's loop, training and draft grading, CutKit's letter |
15
+
16
+ `./jev` reads no env and keeps no cache. The caller passes the key, the model and, if it wants
17
+ one, `fetch` and `retries` (a dropped connection, a 429, a 529 or a 5xx waits 0.5s, 1s, 2s and
18
+ on). Every call gives up after 60 seconds. It throws when a question comes back unanswered, so
19
+ nobody grades a partial set. A disk or database cache wraps it in the app, keyed on
20
+ `questionBody`.
21
+
22
+ `./grade` is pure. Training reads the same `encode` and `predict` that grade a draft, so a model
23
+ grades the way it was fitted.
24
+
25
+ Every evaluator grades on one scale. `rankOn(ruler, value)` is the share of a sorted corpus
26
+ ruler a value beats, in tenths, and `letterGrade` reads that as A+ to F with 8 as the B- bar.
27
+ So a B- beats 80% of its corpus, whether it's a Short, a LinkedIn post or a Reddit reply. A
28
+ trained model's ruler holds its predicted probabilities. CutKit's check maps answers to
29
+ hand-written items with fixed weights, so its ruler holds the craft scores of its corpus
30
+ scripts (`pnpm eval:ruler` in cutkit). Training (logistic regression, the corpus rows) stays in
31
+ UpSys, because it reads local corpora.
13
32
 
14
33
  Fix copy never lives here. A question in the bank says what to ask; the app says what to tell
15
34
  the person when the answer drags the grade down.
@@ -17,8 +36,8 @@ the person when the answer drags the grade down.
17
36
  ## Adding a question
18
37
 
19
38
  Add it to `storyQuestions` with its `source`. It earns a place in a surface once
20
- `pnpm jev train <surface>` in UpSys keeps it, and in CutKit's check once it has done that on
21
- `short`. The `script` wording CutKit already ships is pinned in `questions.test.mjs`, because the
39
+ `pnpm jev train <surface>` in UpSys keeps it, and in CutKit's check once its rating bench
40
+ (`evals/rating`) keeps it. The `script` wording CutKit already ships is pinned in `questions.test.mjs`, because the
22
41
  judge's answers move when a word does.
23
42
 
24
43
  ## Calibration
@@ -28,6 +47,13 @@ over 5,089 punctuated Short transcripts from 23 creators, a sentence-rhythm rule
28
47
  a row at one length) fired on 30.5% of winners and 27.9% of the rest, so it isn't here. `flatRun`
29
48
  flags 57% of winners and 60% of the rest: CutKit shows it as a nudge, never a fail.
30
49
 
50
+ The same day, a Jev discovery round asked every question in the bank on each surface. Only
51
+ `villain` earned a place: on `post` (+0.0020 AUC, coefficient +0.16) and `x` (+0.0025, +0.12).
52
+ `intro_order` helped `opening`'s ranking with a negative coefficient (W-order openings did worse
53
+ there), and `stakes`, `visual_first`, `head_fake` and `atomic_share` added nothing or pointed
54
+ backwards. They stay in the bank to test again. UpSys's `short` surface, whose song-lyric
55
+ transcripts pulled its AUC to 0.52, is gone: CutKit's check grades Shorts.
56
+
31
57
  ```
32
58
  pnpm test
33
59
  ```
package/grade.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { Answer, JevQuestion } from "#social/jev.js";
2
+
3
+ export type Question = JevQuestion & {
4
+ /** Code-only name. Jev never sees it, so the instructions carry the whole meaning. */
5
+ id: string;
6
+ /** Where the question came from: a control-tested lever, Kallaway, a hook-library shape, a discovery round. */
7
+ source?: string;
8
+ /** Said to the writer when this answer is what's pulling the grade down. */
9
+ fix?: string;
10
+ /** Training checks fix direction against the learned coefficient because advice must counter the trait dragging the grade. */
11
+ fixPushes?: "more" | "less";
12
+ /** Answered and weighed so the other coefficients aren't skewed by a tactic we ban, but never offered as advice. */
13
+ control?: true;
14
+ };
15
+
16
+ export type Fit = {
17
+ mean: number[];
18
+ sd: number[];
19
+ coef: number[];
20
+ intercept: number;
21
+ };
22
+
23
+ export type Model<Surface extends string = string> = Fit & {
24
+ surface: Surface;
25
+ trainedAt: string;
26
+ questions: Question[];
27
+ features: string[];
28
+ /** Sorted predicted probabilities over the training corpus, the ruler a grade is read off. */
29
+ ruler: number[];
30
+ metrics: {
31
+ cvAuc: number;
32
+ testAuc: number | null;
33
+ ownAuc: number | null;
34
+ rows: number;
35
+ positives: number;
36
+ };
37
+ };
38
+
39
+ export type TrainedModel<Surface extends string = string> = Model<Surface> & {
40
+ winners: { creator: string; text: string; breakout: number; x: number[] }[];
41
+ };
42
+
43
+ export type Weak = {
44
+ question: string;
45
+ answer: string;
46
+ pull: number;
47
+ fix?: string;
48
+ };
49
+
50
+ export type Grade<Surface extends string = string> = {
51
+ surface: Surface;
52
+ grade: number;
53
+ confidence: number;
54
+ pass: boolean;
55
+ /** False when the model failed its held-out test: the grade is advice and can't block a ship. */
56
+ gates: boolean;
57
+ testAuc: number | null;
58
+ weakest: Weak[];
59
+ winners: { creator: string; text: string; breakout: number }[];
60
+ };
61
+
62
+ export declare const PASS_GRADE: number;
63
+ export declare const MIN_CONFIDENCE: number;
64
+ export declare const MIN_TEST_AUC: number;
65
+ /** The 0 to 10 grade as A+ to F. 8 is the B- bar. */
66
+ export declare function letterGrade(grade: number): string;
67
+ /** A value's rank on a sorted ruler of corpus scores, as a 0 to 10 grade: the share it beats. */
68
+ export declare function rankOn(ruler: number[], value: number): number;
69
+ export declare function encode(questions: Question[], answers: Record<string, Answer>): number[];
70
+ export declare function predict(fit: Fit, x: number[]): number;
71
+ export declare function contributions(fit: Fit, x: number[]): number[];
72
+ export declare function gradeAnswers<Surface extends string>(
73
+ model: TrainedModel<Surface>,
74
+ answers: Record<string, Answer>,
75
+ ): Grade<Surface>;
package/grade.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Jev's answers to a grade on a trained model's corpus ruler. Pure. Training reads the same
3
+ * encode and predict, so a model grades the way it was fitted.
4
+ */
5
+
6
+ /** The grade a piece needs before it ships. */
7
+ export const PASS_GRADE = 8;
8
+ /** Under this confidence Jev is closer to guessing, so a gating model won't pass the piece. */
9
+ export const MIN_CONFIDENCE = 0.6;
10
+ /** Only a model that ranks unseen winners above controls can block shipping. The rest advise. */
11
+ export const MIN_TEST_AUC = 0.6;
12
+
13
+ /**
14
+ * The grade times ten, so the bar of 8 reads B-. Every grade comes off `rankOn`, so a B-
15
+ * beats 80% of its corpus on any surface.
16
+ */
17
+ const LETTERS = [
18
+ [97, "A+"],
19
+ [93, "A"],
20
+ [90, "A-"],
21
+ [87, "B+"],
22
+ [83, "B"],
23
+ [80, "B-"],
24
+ [77, "C+"],
25
+ [73, "C"],
26
+ [70, "C-"],
27
+ [67, "D+"],
28
+ [63, "D"],
29
+ [60, "D-"],
30
+ ];
31
+
32
+ /** @param {number} grade 0 to 10 */
33
+ export function letterGrade(grade) {
34
+ const score = Math.round(grade * 10);
35
+ return LETTERS.find(([floor]) => score >= floor)?.[1] ?? "F";
36
+ }
37
+
38
+ /**
39
+ * The share of a sorted corpus ruler a value beats, as a 0 to 10 grade.
40
+ * @param {number[]} ruler sorted ascending
41
+ * @param {number} value
42
+ */
43
+ export function rankOn(ruler, value) {
44
+ if (!ruler.length) return 0;
45
+ const below = ruler.filter((r) => r < value).length;
46
+ return Number(((below / ruler.length) * 10).toFixed(1));
47
+ }
48
+
49
+ /** A score answer as a fraction of the scale; no answer sits mid scale. */
50
+ function encodeScore(levels, a) {
51
+ return (a?.score ?? (levels - 1) / 2) / (levels - 1);
52
+ }
53
+
54
+ /** One column per choice past the first (the baseline): its probability, else 1 for the pick. */
55
+ function encodeChoice(criteria, a) {
56
+ return Object.keys(criteria)
57
+ .slice(1)
58
+ .map((k) => a?.probabilities?.[k] ?? (a?.choice === k ? 1 : 0));
59
+ }
60
+
61
+ function encodeOne(q, a) {
62
+ if (q.type === "noul") return [a?.noul ?? 0.5];
63
+ if (q.type === "score") return [encodeScore(q.criteria.length, a)];
64
+ return encodeChoice(q.criteria, a);
65
+ }
66
+
67
+ /** One number per feature, in the order a model's `features` lists them. */
68
+ export function encode(questions, answers) {
69
+ return questions.flatMap((q) => encodeOne(q, answers[q.id]));
70
+ }
71
+
72
+ const sigmoid = (z) => 1 / (1 + Math.exp(-z));
73
+ const standardized = (f, x, j) => ((x[j] - (f.mean[j] ?? 0)) / (f.sd[j] ?? 1)) * (f.coef[j] ?? 0);
74
+
75
+ export function predict(f, x) {
76
+ return sigmoid(f.intercept + x.reduce((sum, _, j) => sum + standardized(f, x, j), 0));
77
+ }
78
+
79
+ /** Each feature's pull on the logit, against an average corpus row. */
80
+ export function contributions(f, x) {
81
+ return x.map((_, j) => standardized(f, x, j));
82
+ }
83
+
84
+ function describe(a, q) {
85
+ if (!a) return "no answer";
86
+ if (q.type === "noul") return `yes ${Math.round((a.noul ?? 0) * 100)}%`;
87
+ if (q.type === "score") {
88
+ const levels = q.criteria;
89
+ const i = Math.max(0, Math.min(levels.length - 1, Math.round(a.score ?? 0)));
90
+ return `level ${i + 1} of ${levels.length}: "${levels[i]}"`;
91
+ }
92
+ return `picked "${a.choice}"`;
93
+ }
94
+
95
+ /** Each question's pull: the sum over its features, against an average corpus row. */
96
+ function pullsOf(model, contrib) {
97
+ return model.questions.map((q) => {
98
+ const cols = model.features
99
+ .map((f, j) => [f, j])
100
+ .filter(([f]) => f === q.id || f.startsWith(`${q.id}=`));
101
+ return { q, pull: cols.reduce((sum, [, j]) => sum + (contrib[j] ?? 0), 0) };
102
+ });
103
+ }
104
+
105
+ /** Confidence that counts is the confidence on the answers doing the work. */
106
+ function confidenceOf(pulls, answers) {
107
+ let weight = 0;
108
+ let sum = 0;
109
+ for (const { q, pull } of pulls) {
110
+ const c = answers[q.id]?.confidence;
111
+ if (c === undefined) continue;
112
+ weight += Math.abs(pull);
113
+ sum += Math.abs(pull) * c;
114
+ }
115
+ return Number((weight ? sum / weight : 1).toFixed(2));
116
+ }
117
+
118
+ /** The winners nearest the answers, with distance weighted by each feature's coefficient. */
119
+ function nearestWinners(model, x) {
120
+ const z = (v, j) =>
121
+ ((v - (model.mean[j] ?? 0)) / (model.sd[j] ?? 1)) * Math.abs(model.coef[j] ?? 0);
122
+ const zx = x.map(z);
123
+ return model.winners
124
+ .map((w) => ({ w, d: w.x.reduce((sum, v, j) => sum + (z(v, j) - (zx[j] ?? 0)) ** 2, 0) }))
125
+ .toSorted((a, b) => a.d - b.d)
126
+ .slice(0, 3)
127
+ .map(({ w }) => ({ creator: w.creator, text: w.text, breakout: w.breakout }));
128
+ }
129
+
130
+ /**
131
+ * Where the answers rank, what drags them down, and the nearest winners. Under `MIN_TEST_AUC`
132
+ * a model only advises: `gates` false, `pass` true.
133
+ */
134
+ export function gradeAnswers(model, answers) {
135
+ const x = encode(model.questions, answers);
136
+ const fitted = { mean: model.mean, sd: model.sd, coef: model.coef, intercept: model.intercept };
137
+ const p = predict(fitted, x);
138
+ const grade = rankOn(model.ruler, p);
139
+
140
+ const pulls = pullsOf(model, contributions(fitted, x));
141
+ const confidence = confidenceOf(pulls, answers);
142
+ const weakest = pulls
143
+ .filter((e) => e.pull < 0 && !e.q.control)
144
+ .toSorted((a, b) => a.pull - b.pull)
145
+ .slice(0, 3)
146
+ .map(({ q, pull }) => ({
147
+ question: q.instructions,
148
+ answer: describe(answers[q.id], q),
149
+ pull: Number(pull.toFixed(2)),
150
+ fix: q.fix,
151
+ }));
152
+
153
+ const testAuc = model.metrics.testAuc;
154
+ const gates = testAuc !== null && testAuc >= MIN_TEST_AUC;
155
+ const pass = !gates || (grade >= PASS_GRADE && confidence >= MIN_CONFIDENCE);
156
+ return {
157
+ surface: model.surface,
158
+ grade,
159
+ confidence,
160
+ pass,
161
+ gates,
162
+ testAuc,
163
+ weakest,
164
+ winners: nearestWinners(model, x),
165
+ };
166
+ }
package/jev.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /** One answer from Jev. Which fields are set depends on the question's type. */
2
+ export type Answer = {
3
+ type?: string;
4
+ noul?: number;
5
+ score?: number;
6
+ choice?: string;
7
+ probabilities?: Record<string, number>;
8
+ confidence?: number;
9
+ };
10
+
11
+ export type JevQuestion =
12
+ | { type: "noul"; instructions: string; criteria?: undefined }
13
+ /** Ordered low to high, 2 to 10 levels. */
14
+ | { type: "score"; instructions: string; criteria: string[] }
15
+ /** Option key to its meaning. */
16
+ | { type: "choice"; instructions: string; criteria: Record<string, string> };
17
+
18
+ export declare const JEV_URL: string;
19
+ export declare function questionBody(q: JevQuestion): JevQuestion;
20
+
21
+ export declare function askJev(request: {
22
+ key: string;
23
+ model: string;
24
+ state: unknown;
25
+ questions: Record<string, JevQuestion>;
26
+ url?: string;
27
+ fetch?: typeof fetch;
28
+ retries?: number;
29
+ }): Promise<{ answers: Record<string, Answer>; model: string }>;
package/jev.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The wire shape of a Jev question and the one call that answers it. No env reads and no
3
+ * cache: the app passes its key and model and caches.
4
+ */
5
+
6
+ /** TypeSafe's /v1/systemone behind Kilo, which bills our Kilo credits. */
7
+ export const JEV_URL = "https://api.kilo.ai/api/gateway/typesafe/v1/systemone";
8
+
9
+ /** A dead socket hung a run for 15 hours on 14 Oct 2026, so every call gives up at this. */
10
+ const TIMEOUT_MS = 60_000;
11
+
12
+ /**
13
+ * What Jev sees of a question. The app's id, fix and source stay out, so a cache key hashed
14
+ * from this survives a change to them.
15
+ */
16
+ export function questionBody(q) {
17
+ return q.type === "noul"
18
+ ? { type: "noul", instructions: q.instructions }
19
+ : { type: q.type, instructions: q.instructions, criteria: q.criteria };
20
+ }
21
+
22
+ const retryable = (status) => status === 429 || status === 529 || status >= 500;
23
+ const backoff = (attempt) => new Promise((done) => setTimeout(done, 500 * 2 ** attempt));
24
+
25
+ async function post(request, attempt) {
26
+ const { key, model, state, questions, url = JEV_URL, fetch: send = fetch, retries = 0 } = request;
27
+ let res;
28
+ try {
29
+ res = await send(url, {
30
+ method: "POST",
31
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
32
+ body: JSON.stringify({ state, model, questions }),
33
+ signal: AbortSignal.timeout(TIMEOUT_MS),
34
+ });
35
+ } catch (error) {
36
+ if (attempt >= retries) throw error;
37
+ return backoff(attempt).then(() => post(request, attempt + 1));
38
+ }
39
+ if (retryable(res.status) && attempt < retries)
40
+ return backoff(attempt).then(() => post(request, attempt + 1));
41
+ if (!res.ok) {
42
+ const detail = await res.text().catch(() => "");
43
+ throw new Error(`Jev answered ${res.status}: ${detail.slice(0, 200)}`);
44
+ }
45
+ return res.json();
46
+ }
47
+
48
+ /**
49
+ * @typedef {{
50
+ * key: string,
51
+ * model: string,
52
+ * state: unknown,
53
+ * questions: Record<string, ReturnType<typeof questionBody>>,
54
+ * url?: string,
55
+ * fetch?: typeof fetch,
56
+ * retries?: number,
57
+ * }} JevCall
58
+ */
59
+
60
+ /**
61
+ * Asks every question about one state. A drop, 429, 529 or 5xx retries after 0.5s, 1s, 2s and
62
+ * on. A missing answer throws.
63
+ * @param {JevCall} request
64
+ */
65
+ export async function askJev(request) {
66
+ const reply = await post(request, 0);
67
+ for (const id of Object.keys(request.questions)) {
68
+ if (!reply.answers?.[id]) throw new Error(`Jev returned no answer for ${id}`);
69
+ }
70
+ return { answers: reply.answers, model: reply.model ?? request.model };
71
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@heyramzi/social",
3
- "version": "1.0.0",
4
- "description": "The social-media heuristics CutKit and UpSys share: hook shapes, the Shorts audit, the story checks with no model in them, and the story question bank a judge model answers",
3
+ "version": "1.1.0",
4
+ "description": "Content evaluation CutKit and UpSys share: hook shapes, the Shorts audit, story checks with no model, the story question bank, and the Jev call and trained-model grade",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -9,8 +9,12 @@
9
9
  "directory": "packages/social"
10
10
  },
11
11
  "files": [
12
+ "grade.js",
13
+ "grade.d.ts",
12
14
  "hooks.js",
13
15
  "hooks.d.ts",
16
+ "jev.js",
17
+ "jev.d.ts",
14
18
  "questions.js",
15
19
  "questions.d.ts",
16
20
  "shorts.js",
@@ -24,10 +28,18 @@
24
28
  "#social/*": "./*"
25
29
  },
26
30
  "exports": {
31
+ "./grade": {
32
+ "types": "./grade.d.ts",
33
+ "default": "./grade.js"
34
+ },
27
35
  "./hooks": {
28
36
  "types": "./hooks.d.ts",
29
37
  "default": "./hooks.js"
30
38
  },
39
+ "./jev": {
40
+ "types": "./jev.d.ts",
41
+ "default": "./jev.js"
42
+ },
31
43
  "./questions": {
32
44
  "types": "./questions.d.ts",
33
45
  "default": "./questions.js"