Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type * as map from "../map.js";
import type * as neurons from "../neurons.js";
import type * as players from "../players.js";
import type * as rules from "../rules.js";
import type * as stats from "../stats.js";
import type * as worker from "../worker.js";

import type {
Expand All @@ -41,6 +42,7 @@ declare const fullApi: ApiFromModules<{
neurons: typeof neurons;
players: typeof players;
rules: typeof rules;
stats: typeof stats;
worker: typeof worker;
}>;

Expand Down
2 changes: 1 addition & 1 deletion convex/accountLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Pure production coordinator used by SessionProvider; no browser, Clerk client or network.
import { expect, test } from "vitest";
import { createAccountGate, type Outcome } from "../src/lib/accountGate";
import { createAccountGate, type Outcome } from "../src/players/accountGate";

const setup = () => {
const gate = createAccountGate();
Expand Down
27 changes: 11 additions & 16 deletions convex/admin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { ConvexError, v } from "convex/values";
import { internalMutation, internalQuery } from "./_generated/server";
import { bumpTally } from "./daily";
import { COVERAGE_BINS, globalStats } from "./jobs";
import { MODEL, neuronKey } from "./lib";
import { bumpTally, dailyFor } from "./daily";
import { profileOf } from "./lib";
import { neuronKey } from "./rules";
import { countCatches, globalStats } from "./stats";

// Operator tools, run with `npx convex run admin:<name>`. Not callable from the client.

Expand All @@ -26,16 +27,15 @@ export const resetDailies = internalMutation({
export const answer = internalQuery({
args: { date: v.string() },
handler: async (ctx, { date }) => {
const d = await ctx.db.query("dailies").withIndex("by_date", (q) => q.eq("date", date)).unique();
const d = await dailyFor(ctx, date);
return d ? { layer: d.layer, n: d.n } : null;
},
});

// Whether Convex already has a neuron's measurements. The E2E guesses one that doesn't, so its guess needs the worker.
export const profiled = internalQuery({
args: { layer: v.number(), n: v.number() },
handler: async (ctx, { layer, n }) =>
!!(await ctx.db.query("profiles").withIndex("by_key", (q) => q.eq("key", neuronKey(layer, n))).unique()),
handler: async (ctx, { layer, n }) => !!(await profileOf(ctx, neuronKey(layer, n))),
});

// The kinds of jobs the worker is running right now.
Expand All @@ -53,15 +53,15 @@ export const resetPlayers = internalMutation({
handler: async (ctx) => {
let n = 0;
for (const c of await ctx.db.query("catches").collect()) {
const p = await ctx.db.query("profiles").withIndex("by_key", (q) => q.eq("key", c.key)).unique();
const p = await profileOf(ctx, c.key);
if (p) await ctx.db.patch(p._id, { caught: false });
}
for (const t of ["scans", "catches", "guesses", "results", "opens", "tallies", "entries", "standings", "players"] as const)
for (const d of await ctx.db.query(t).collect()) {
await ctx.db.delete(d._id);
n++;
}
const s = await ctx.db.query("stats").withIndex("by_name", (q) => q.eq("name", "global")).unique();
const s = await globalStats(ctx);
if (s) await ctx.db.patch(s._id, { caught: 0, coverage: s.coverage.map(() => 0) });
return n;
},
Expand All @@ -80,20 +80,15 @@ export const removePlayer = internalMutation({
const p = await ctx.db.query("players").withIndex("by_handle", (q) => q.eq("handleLower", handle.toLowerCase())).unique();
if (!p) return false;
const catches = await ctx.db.query("catches").withIndex("by_player", (q) => q.eq("playerId", p._id)).collect();
const s = await globalStats(ctx);
if (s && catches.length) {
const coverage = [...s.coverage];
for (const c of catches) coverage[c.layer * COVERAGE_BINS + Math.floor((c.n * COVERAGE_BINS) / MODEL.width)] -= 1;
await ctx.db.patch(s._id, { caught: s.caught - catches.length, coverage });
}
if (catches.length) await countCatches(ctx, catches, -1);
for (const c of catches) {
const prof = await ctx.db.query("profiles").withIndex("by_key", (q) => q.eq("key", c.key)).unique();
const prof = await profileOf(ctx, c.key);
if (prof) await ctx.db.patch(prof._id, { caught: false });
await ctx.db.delete(c._id);
}
// their results leave the puzzles' player and solver counts
for (const r of await ctx.db.query("results").withIndex("by_player_date", (q) => q.eq("playerId", p._id)).collect()) {
const d = await ctx.db.query("dailies").withIndex("by_date", (q) => q.eq("date", r.date)).unique();
const d = await dailyFor(ctx, r.date);
if (d && !r.practice) await bumpTally(ctx, d, r.solved, -1);
}
// their places on bounty leaderboards
Expand Down
14 changes: 7 additions & 7 deletions convex/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,11 @@ test("one player's daily solve doesn't reveal the answer, hints or guesses to an
await alice.mutation(api.daily.guess, { layer: 1, n: 1 });
await bob.mutation(api.daily.guess, { layer: 1, n: 2 });
const a = (await alice.query(api.daily.current, {}))!;
expect([a.result?.solved, a.answer?.layer, a.answer?.n]).toEqual([true, 1, 99]);
expect([a.result?.solved, a.result?.answer.layer, a.result?.answer.n]).toEqual([true, 1, 99]);
expect(a.band).toEqual([0, 7]);
expect(a.hint).toBe("private hint");
for (const viewer of [bob, t]) {
expect(await viewer.query(api.daily.current, {})).toMatchObject({ result: null, answer: null, band: null, hint: null });
expect(await viewer.query(api.daily.current, {})).toMatchObject({ result: null, band: null, hint: null });
expect(await viewer.query(api.daily.tally, { date: DATE })).toEqual({ players: 1, solvers: 1 });
}
expect((await bob.query(api.daily.current, {}))!.guesses.map((g) => g.n)).toEqual([2]);
Expand All @@ -100,10 +100,10 @@ test("a guest's game stays theirs: hidden from the leaderboard, never shown to a
await t.mutation(api.worker.putProfiles, { secret: SECRET, jobIds: [], profiles: [profile(1, axis(0))] });
const guest = "guest-token-0123456789";
await t.mutation(api.daily.guess, { layer: 1, n: 1, guest });
expect((await t.query(api.daily.current, { guest }))!.answer).toMatchObject({ layer: 1, n: 99 });
expect((await t.query(api.daily.current, { guest }))!.result?.answer).toMatchObject({ layer: 1, n: 99 });
expect((await t.query(api.players.leaderboard, {})).map((p) => p.handle).sort()).toEqual(["Alice", "Bob"]);
// a signed-in caller holding the token still gets their own game
expect(await alice.query(api.daily.current, { guest })).toMatchObject({ result: null, answer: null });
expect(await alice.query(api.daily.current, { guest })).toMatchObject({ result: null });
const carol = t.withIdentity({ tokenIdentifier: "test|carol" });
await carol.mutation(api.players.join, { handle: "Carol", guest });
expect((await carol.query(api.daily.current, {}))!.result?.solved).toBe(true);
Expand Down Expand Up @@ -142,7 +142,7 @@ test("bounty entries and judged replies stay private until the bounty closes; th
const { t, alice, bob, seedBounty } = await setup();
await seedBounty();
await alice.mutation(api.bounties.submit, { slug: "isolation", pushes });
const job = (await t.run((ctx) => ctx.db.query("jobs").collect()))[0];
const job = (await t.run((ctx) => ctx.db.query("jobs").collect())).find((j) => j.kind === "judge")!;
await t.mutation(api.worker.finishJudge, {
secret: SECRET, id: job._id, entryId: job.args.entryId, score: 700, concept: 0.8, coherence: 0.9, texts: ["alice judged reply"],
});
Expand Down Expand Up @@ -198,7 +198,7 @@ test("every worker endpoint refuses a wrong secret, and all of them refuse when
const dailyId = await seedDaily();
const { entryId, jobId } = await t.run(async (ctx) => {
const entryId = await ctx.db.insert("entries", { playerId: bobId, bountyId, pushes, status: "pending" });
const jobId = await ctx.db.insert("jobs", { kind: "judge", key: "job", args: { entryId }, priority: 0, status: "pending", by: bobId });
const jobId = await ctx.db.insert("jobs", { kind: "judge", key: "job", args: { entryId, slug: "isolation", pushes, prompts: [], anchors: [] }, priority: 0, status: "pending", by: bobId });
return { entryId, jobId };
});
// Valid arguments apart from the secret, so the guard (not the validator) is what refuses.
Expand Down Expand Up @@ -306,7 +306,7 @@ test("a lab callback writes only the admitted owner's private experiment after a
});
expect(await bob.query(api.lab.mine, {})).toEqual([]);
expect(await t.query(api.lab.mine, {})).toEqual([]);
expect(await alice.query(api.lab.mine, {})).toMatchObject([{ id, status: "done", texts: ["private lab output"] }]);
expect(await alice.query(api.lab.mine, {})).toMatchObject([{ id, status: "done", result: { texts: ["private lab output"] } }]);
});


Expand Down
29 changes: 12 additions & 17 deletions convex/bounties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server";
import { BOUNTY_DAYS, BOUNTY_STAGGER_HOURS, JUDGE_PROMPTS, POOL } from "./bountyPool";
import { BOUNTY_STAGGER_HOURS, JUDGE_PROMPTS, POOL } from "./bountyPool";
import { enqueue } from "./jobs";
import { LIMITS, playerFor, requirePlayer, validNeuron } from "./lib";
import { checkPush, playerFor, requirePlayer } from "./lib";
import { BOUNTY_DAYS, LIMITS, neuronKey } from "./rules";
import { push } from "./schema";

const OPEN = 3; // bounties open at once
export const PRIZES = [50, 25, 10]; // points for the top three when a bounty closes; a top-three finish that beat par adds PAR_BONUS
export const PAR_BONUS = 10;
export const DOSE = { min: 0.05, max: 1.0 };

// A bounty's public fields. Whether it's open is left to the client's clock (rules.ts bountyState).
function summary(b: Doc<"bounties">) {
Expand Down Expand Up @@ -114,25 +114,19 @@ export const get = query({

export const submit = mutation({
args: { slug: v.string(), pushes: v.array(push) },
handler: async (ctx, { slug, pushes }) => {
handler: async (ctx, { slug, pushes: asked }) => {
const player = await requirePlayer(ctx);
const b = await ctx.db.query("bounties").withIndex("by_slug", (q) => q.eq("slug", slug)).unique();
const now = Date.now();
if (!b || now < b.startsAt || now >= b.endsAt) throw new ConvexError("This bounty isn't open");
if (pushes.length < 1 || pushes.length > b.maxNeurons) throw new ConvexError(`Use 1-${b.maxNeurons} neurons`);
const keys = new Set<string>();
for (const p of pushes) {
validNeuron(p.layer, p.n);
if (!Number.isFinite(p.dose) || Math.abs(p.dose) < DOSE.min || Math.abs(p.dose) > DOSE.max)
throw new ConvexError(`Doses are ${DOSE.min} to ${DOSE.max} (or negative)`);
keys.add(`${p.layer}:${p.n}`);
}
if (keys.size !== pushes.length) throw new ConvexError("Each neuron once");
if (asked.length < 1 || asked.length > b.maxNeurons) throw new ConvexError(`Use 1-${b.maxNeurons} neurons`);
const pushes = asked.map(checkPush);
if (new Set(pushes.map((p) => neuronKey(p.layer, p.n))).size !== pushes.length) throw new ConvexError("Each neuron once");
const mine = await ctx.db.query("entries").withIndex("by_player_bounty", (q) => q.eq("playerId", player._id).eq("bountyId", b._id)).collect();
if (mine.length >= LIMITS.entries) throw new ConvexError(`Out of entries for this bounty (${LIMITS.entries})`);
const pushesR = pushes.map((p) => ({ ...p, dose: Math.round(p.dose * 100) / 100 }));
const entryId = await ctx.db.insert("entries", { bountyId: b._id, playerId: player._id, pushes: pushesR, status: "pending" });
await enqueue(ctx, "judge", `judge:${entryId}`, { entryId, slug, pushes: pushesR, prompts: b.prompts, anchors: b.anchors, ...(b.question ? { question: b.question } : {}) }, 0, player._id);
const entryId = await ctx.db.insert("entries", { bountyId: b._id, playerId: player._id, pushes, status: "pending" });
const args = { entryId, slug, pushes, prompts: b.prompts, anchors: b.anchors, ...(b.question ? { question: b.question } : {}) };
await enqueue(ctx, { kind: "judge", args }, `judge:${entryId}`, 0, player._id);
},
});

Expand Down Expand Up @@ -172,7 +166,8 @@ export const ensure = internalMutation({
ranked: true,
});
await ctx.scheduler.runAt(endsAt, internal.bounties.settle, {});
await enqueue(ctx, "par", `par:${slug}`, { slug, prompts: JUDGE_PROMPTS, anchors: next.anchors, question: next.question, maxNeurons: next.maxNeurons, brief: next.brief }, 1);
const args = { slug, prompts: JUDGE_PROMPTS, anchors: next.anchors, question: next.question, maxNeurons: next.maxNeurons, brief: next.brief };
await enqueue(ctx, { kind: "par", args }, `par:${slug}`, 1);
},
});

Expand Down
5 changes: 2 additions & 3 deletions convex/bountyPool.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// Bounties rotate through this pool: a new one starts every BOUNTY_STAGGER_HOURS and runs BOUNTY_DAYS, so three are
// open at once. The judge runs entries on JUDGE_PROMPTS (never shown to players) and asks the unsteered model the
// Bounties rotate through this pool: a new one starts every BOUNTY_STAGGER_HOURS and runs BOUNTY_DAYS (rules.ts), so
// three are open at once. The judge runs entries on JUDGE_PROMPTS (never shown to players) and asks the unsteered model the
// bounty's yes/no question about each reply. The anchors are on-target replies: the house's par search looks for neurons
// that push their words.
export const BOUNTY_DAYS = 7;
export const BOUNTY_STAGGER_HOURS = 56;

export const JUDGE_PROMPTS = [
Expand Down
Loading