@hviana/sema 0.1.6 → 0.1.7

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.
@@ -9,7 +9,7 @@ import { ALL } from "./types.js";
9
9
  * collision-free encoding. Spans on the perception path are query-scale
10
10
  * (windows, regions, candidate spans), so key construction is far cheaper
11
11
  * than the river fold it deduplicates. */
12
- function latin1Key(bytes) {
12
+ export function latin1Key(bytes) {
13
13
  // Batched String.fromCharCode — avoids the O(n²) cost of repeated += on
14
14
  // potentially-large query spans, and stays well under the ~65536 arg limit.
15
15
  const n = bytes.length;
@@ -20,8 +20,14 @@ function latin1Key(bytes) {
20
20
  return s;
21
21
  }
22
22
  /** Perceive input into a content-defined tree (the river fold).
23
- * Deterministic — identical bytes always produce an identical tree. */
24
- export function perceive(ctx, input, leafAt, lookup) {
23
+ * Deterministic — identical bytes always produce an identical tree.
24
+ *
25
+ * `boundaries` is an optional sorted list of proper byte offsets where the
26
+ * fold must split so that each prefix segment folds identically to how it
27
+ * folded when it was learned (§10.3 stable-prefix contract). Only the
28
+ * CALLER — who assembled the multi-turn context — knows where those
29
+ * boundaries are; the geometry never guesses them from the bytes. */
30
+ export function perceive(ctx, input, leafAt, lookup, boundaries) {
25
31
  if (typeof input === "string" || input instanceof Uint8Array) {
26
32
  const bytes = typeof input === "string"
27
33
  ? new TextEncoder().encode(input)
@@ -37,11 +43,11 @@ export function perceive(ctx, input, leafAt, lookup) {
37
43
  const hit = memo.get(key);
38
44
  if (hit !== undefined)
39
45
  return hit;
40
- const tree = bytesToTree(ctx.space, ctx.alphabet, bytes);
46
+ const tree = bytesToTree(ctx.space, ctx.alphabet, bytes, undefined, undefined, boundaries);
41
47
  memo.set(key, tree);
42
48
  return tree;
43
49
  }
44
- return bytesToTree(ctx.space, ctx.alphabet, bytes);
50
+ return bytesToTree(ctx.space, ctx.alphabet, bytes, undefined, undefined, boundaries);
45
51
  }
46
52
  return bytesToTree(ctx.space, ctx.alphabet, bytes, leafAt, lookup);
47
53
  }
@@ -105,11 +111,24 @@ export function gistOf(ctx, bytes) {
105
111
  * node with its byte span and resolved id. Returns the node's byte end and
106
112
  * resolved id. */
107
113
  export function foldTree(ctx, n, start, visit) {
114
+ // Fast path: subtree already resolved (from a previous conversation turn
115
+ // or an earlier recognition pass). The pyramid reuses prefix subtrees as
116
+ // identical Sema objects, so this cache turns foldTree into O(suffix)
117
+ // instead of O(context) for multi-turn recognition.
118
+ const cached = ctx._resolvedSubtrees?.get(n);
119
+ if (cached !== undefined) {
120
+ const end = start + cached.len;
121
+ visit?.(n, start, end, cached.id);
122
+ return { end, node: cached.id };
123
+ }
108
124
  if (n.kids === null) {
109
125
  const b = n.leaf ?? new Uint8Array(0);
110
126
  const end = start + b.length;
111
127
  const node = ctx.store.findLeaf(b);
112
128
  visit?.(n, start, end, node);
129
+ if (node !== null && ctx._resolvedSubtrees) {
130
+ ctx._resolvedSubtrees.set(n, { id: node, len: b.length });
131
+ }
113
132
  return { end, node };
114
133
  }
115
134
  let pos = start;
@@ -125,6 +144,9 @@ export function foldTree(ctx, n, start, visit) {
125
144
  }
126
145
  const node = known ? ctx.store.findBranch(kids) : null;
127
146
  visit?.(n, start, pos, node);
147
+ if (node !== null && ctx._resolvedSubtrees) {
148
+ ctx._resolvedSubtrees.set(n, { id: node, len: pos - start });
149
+ }
128
150
  return { end: pos, node };
129
151
  }
130
152
  /** The canonical node id of a byte span: perceive it in isolation — the way
@@ -5,8 +5,8 @@
5
5
  // that leads somewhere (has a continuation edge or a halo).
6
6
  // segment — leaf-parent segmentation using the geometry's own groupings.
7
7
  import { rItem } from "./trace.js";
8
- import { foldTree, gistOf, perceive, resolve } from "./primitives.js";
9
- import { leadsSomewhere } from "./traverse.js";
8
+ import { foldTree, gistOf, latin1Key, perceive, resolve, } from "./primitives.js";
9
+ import { atomIsHub, corpusN, leadsSomewhere } from "./traverse.js";
10
10
  import { chainReach, leafIdAt } from "./canonical.js";
11
11
  import { isChunk } from "../sema.js";
12
12
  /** Decompose a byte stream into every stored form that leads somewhere
@@ -22,16 +22,16 @@ import { isChunk } from "../sema.js";
22
22
  *
23
23
  * Both O(n · maxGroup) bounded O(1) probes — never a scan of the corpus. */
24
24
  export function recognise(ctx, bytes) {
25
- // Per-response memo (see MindContext.recogniseMemo): think, articulate and
26
- // the pre-consume pass all recognise the same byte objects; each repeat is
27
- // O(n·maxGroup²) store probes. Skipped while tracing so every call still
28
- // emits its rationale step.
25
+ // Content-keyed memo works for both single-turn respond() and multi-turn
26
+ // respondTurn() (where the map persists across calls). Skipped while
27
+ // tracing so every call still emits its rationale step.
29
28
  if (ctx.recogniseMemo && !ctx.trace) {
30
- const hit = ctx.recogniseMemo.get(bytes);
29
+ const key = latin1Key(bytes);
30
+ const hit = ctx.recogniseMemo.get(key);
31
31
  if (hit !== undefined)
32
32
  return hit;
33
33
  const fresh = recogniseImpl(ctx, bytes);
34
- ctx.recogniseMemo.set(bytes, fresh);
34
+ ctx.recogniseMemo.set(key, fresh);
35
35
  return fresh;
36
36
  }
37
37
  return recogniseImpl(ctx, bytes);
@@ -59,7 +59,20 @@ function recogniseImpl(ctx, bytes) {
59
59
  }
60
60
  return id;
61
61
  };
62
+ // Byte atoms (implicit negative-id single-byte leaves) are admitted as
63
+ // recognised sites only while atoms can still DISCRIMINATE at this corpus
64
+ // scale (see {@link atomIsHub}). On a small store a single-letter fact
65
+ // ("a" → "A") is genuine learnt content and its site is essential; on a
66
+ // large one every letter of every query would otherwise become a
67
+ // "recognised form" — the bridge then finds junction connectors between
68
+ // bare letters, cover follows edges hanging off them, and pure noise
69
+ // ("qq8f3kz9…") grounds to an arbitrary learnt sentence instead of
70
+ // silence. Atoms stay available as leaves (PASS-carried literals) and
71
+ // through exact tier-0 resolution regardless.
72
+ const atomsAreHubs = atomIsHub(ctx, corpusN(ctx));
62
73
  const emit = (start, end, id) => {
74
+ if (id < 0 && atomsAreHubs)
75
+ return;
63
76
  if (leadsSomewhere(ctx, id)) {
64
77
  sites.push({ start, end, payload: id });
65
78
  }
@@ -16,6 +16,19 @@ export declare function edgeAncestors(ctx: MindContext, id: number, contextCount
16
16
  export declare function nextOf(ctx: MindContext, id: number): number[];
17
17
  /** Convenience: reverse edges of a node. */
18
18
  export declare function prevOf(ctx: MindContext, id: number): number[];
19
+ /** The uniform-expectation floor on a byte atom's corpus commonality: N
20
+ * learnt contexts, each at least one perception chunk of up to W of the 256
21
+ * possible byte values, contain a given atom in ≥ N·W/256 contexts on
22
+ * average. An atom's TRUE containment is unmeasurable (atoms carry no
23
+ * kid/contain links by construction), so this floor is the honest stand-in:
24
+ * derived entirely from the corpus scale N, the perception window W, and
25
+ * the alphabet size — never tuned. */
26
+ export declare function atomReach(ctx: MindContext, contextCount: number): number;
27
+ /** Whether a byte atom is a hub at this corpus scale — its commonality floor
28
+ * {@link atomReach} exceeds the hub bound √N. Below it (small stores) an
29
+ * atom votes and is recognised exactly as any stored form; above it the
30
+ * alphabet is scaffolding everywhere and abstains. */
31
+ export declare function atomIsHub(ctx: MindContext, contextCount: number): boolean;
19
32
  /** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
20
33
  * The admission predicate recognition filters sites with (HOW_IT_WORKS
21
34
  * §15.3): a form that leads nowhere contributes nothing to any derivation.
@@ -70,6 +70,29 @@ export function edgeAncestors(ctx, id, contextCount, memo) {
70
70
  const hit = memo?.get(id);
71
71
  if (hit !== undefined)
72
72
  return hit;
73
+ // BYTE-ATOM COMMONALITY. A single-byte leaf (implicit negative id) has no
74
+ // structural parents BY CONSTRUCTION — atoms are never linked into the kid
75
+ // or contain tables — so this climb cannot observe its containment at all.
76
+ // The walk below would see only the atom's own direct edges and report
77
+ // contextsReached ≈ 1, turning the MOST common content in the store into
78
+ // the MOST discriminative voter (observed on a 325K-context store: every
79
+ // recognised single-letter site voted full ln N for the one fact whose
80
+ // continuation is that letter, and their pooled sum out-voted every
81
+ // genuine anchor). An unmeasurable containment must not default to
82
+ // "maximally rare": it is bounded below by the uniform expectation over
83
+ // the byte alphabet — N contexts, each at least one chunk of up to W of
84
+ // the 256 possible atoms, reach ≥ N·W/256 contexts per atom on average
85
+ // (see {@link atomReach}). When that floor itself exceeds the hub bound
86
+ // √N the atom is a hub at this corpus scale and the climb abstains
87
+ // (saturated) — the atom's own edges remain fully traversable (tier-0
88
+ // exact recall, chooseNext, project); only its say as a consensus voter
89
+ // is withdrawn. On a small store the floor stays ≤ √N and the atom
90
+ // climbs exactly as before, so single-letter facts keep working.
91
+ if (id < 0 && atomIsHub(ctx, contextCount)) {
92
+ const reach = { roots: [], contextsReached: 0, saturated: true };
93
+ memo?.set(id, reach);
94
+ return reach;
95
+ }
73
96
  const bound = Math.ceil(Math.sqrt(contextCount));
74
97
  const roots = [];
75
98
  const seen = new Set([id]);
@@ -214,6 +237,24 @@ export function nextOf(ctx, id) {
214
237
  export function prevOf(ctx, id) {
215
238
  return ctx.store.prev(id);
216
239
  }
240
+ /** The uniform-expectation floor on a byte atom's corpus commonality: N
241
+ * learnt contexts, each at least one perception chunk of up to W of the 256
242
+ * possible byte values, contain a given atom in ≥ N·W/256 contexts on
243
+ * average. An atom's TRUE containment is unmeasurable (atoms carry no
244
+ * kid/contain links by construction), so this floor is the honest stand-in:
245
+ * derived entirely from the corpus scale N, the perception window W, and
246
+ * the alphabet size — never tuned. */
247
+ export function atomReach(ctx, contextCount) {
248
+ return Math.max(1, Math.ceil((contextCount * ctx.space.maxGroup) / 256));
249
+ }
250
+ /** Whether a byte atom is a hub at this corpus scale — its commonality floor
251
+ * {@link atomReach} exceeds the hub bound √N. Below it (small stores) an
252
+ * atom votes and is recognised exactly as any stored form; above it the
253
+ * alphabet is scaffolding everywhere and abstains. */
254
+ export function atomIsHub(ctx, contextCount) {
255
+ return atomReach(ctx, contextCount) >
256
+ Math.ceil(Math.sqrt(Math.max(2, contextCount)));
257
+ }
217
258
  /** Whether a node LEADS SOMEWHERE — it bears a continuation edge or a halo.
218
259
  * The admission predicate recognition filters sites with (HOW_IT_WORKS
219
260
  * §15.3): a form that leads nowhere contributes nothing to any derivation.
@@ -105,28 +105,30 @@ export interface MindContext extends GraphSearchHost {
105
105
  cfg: MindConfig;
106
106
  search: GraphSearch;
107
107
  trace: Rationale | null;
108
- climbMemo: WeakMap<Uint8Array, Map<string, AttentionRead>> | null;
109
- /** Per-response memo of {@link recognise} keyed by the byte-array OBJECT
110
- * (think, articulate, and the post-grounding pre-consume all recognise the
111
- * same query/answer objects). Valid because the store is read-only while
112
- * a response is in flight; bypassed when a trace is attached so every
113
- * recognise still emits its rationale step. Null outside respond(). */
114
- recogniseMemo: WeakMap<Uint8Array, Recognition> | null;
115
- /** Per-response memo of {@link perceive} keyed by the byte-array OBJECT —
116
- * the GENERAL memo the result-level ones (recogniseMemo, climbMemo,
117
- * _gistCache) each partially compensate for: resolve(), gistOf(), and
118
- * every mechanism's re-perception of the same query/answer object hit it
119
- * (a reason hop used to fold the same answer three times). Valid because
120
- * the store is read-only while a response is in flight and perception is
121
- * a pure function of bytes; only inference-shaped calls (plain Uint8Array,
122
- * no leafAt/lookup capabilities) are memoised, so the deposit path never
123
- * sees it. Keyed by CONTENT (latin1 of the bytes), not object identity —
124
- * mechanisms materialise the same span in fresh subarrays constantly
125
- * (measured on a trained store: 46% of one response's perceptions were
126
- * byte-identical repeats an identity key missed). NOT bypassed under
127
- * trace — perception emits no rationale steps, so there is nothing a memo
128
- * hit could swallow. Null outside respond(). */
108
+ /** Memo of the consensus climb — content-keyed (latin1) so results
109
+ * persist across conversation turns where the same byte spans recur.
110
+ * Null outside respond(); during respondTurn() the conversation's
111
+ * persistent map is swapped in. */
112
+ climbMemo: Map<string, Map<string, AttentionRead>> | null;
113
+ /** Memo of {@link recognise} content-keyed (latin1) so recognised
114
+ * forms carry forward across conversation turns. Bypassed while a
115
+ * trace is attached. Null outside respond(). */
116
+ recogniseMemo: Map<string, Recognition> | null;
117
+ /** Memo of {@link perceive} content-keyed (latin1). The general
118
+ * cache the result-level memos each partially compensate for. NOT
119
+ * bypassed under trace perception emits no rationale steps.
120
+ * Null outside respond(). */
129
121
  perceiveMemo: Map<string, Sema> | null;
122
+ /** Subtree-resolution cache: Sema node → its store id and byte length.
123
+ * Populated by {@link foldTree} during inference; checked before
124
+ * walking children. When a conversation's pyramid reuses prefix
125
+ * subtrees, this cache lets {@link recognise} skip them entirely —
126
+ * O(suffix) instead of O(context). Mind-lifetime (WeakMap keys are
127
+ * the Sema objects the pyramid keeps alive). */
128
+ _resolvedSubtrees: WeakMap<Sema, {
129
+ id: number;
130
+ len: number;
131
+ }> | null;
130
132
  _edgeGuide: Vec | null;
131
133
  _edgeChoice: Map<number, number>;
132
134
  _prevSeen: Set<number> | null;
@@ -144,6 +144,7 @@ export declare class SQliteStore extends AbstractStore implements Store {
144
144
  protected _vecContentSize(): number;
145
145
  protected _vecContentLastReads(): number;
146
146
  protected _vecContentPhysicalSize(): number;
147
+ protected _vecContentClusterCount(): number;
147
148
  protected _vecContentCompact(): void;
148
149
  protected _vecContentDeleteMany(ids: NodeId[]): void;
149
150
  protected _vecContentEntriesSince(after: number): IterableIterator<{
@@ -164,6 +165,7 @@ export declare class SQliteStore extends AbstractStore implements Store {
164
165
  }>;
165
166
  protected _vecHaloSize(): number;
166
167
  protected _vecHaloPhysicalSize(): number;
168
+ protected _vecHaloClusterCount(): number;
167
169
  protected _vecHaloCompact(): void;
168
170
  /** Pre-fill both vector indices' RAM caches with sequential scans (up to
169
171
  * their budget caps) — seconds of streaming instead of the minutes of
@@ -835,6 +835,9 @@ export class SQliteStore extends AbstractStore {
835
835
  _vecContentPhysicalSize() {
836
836
  return this.content ? this.content.physicalSize : 0;
837
837
  }
838
+ _vecContentClusterCount() {
839
+ return this.content ? this.content.clusterCount : 0;
840
+ }
838
841
  _vecContentCompact() {
839
842
  this.content.compact();
840
843
  }
@@ -869,6 +872,9 @@ export class SQliteStore extends AbstractStore {
869
872
  _vecHaloPhysicalSize() {
870
873
  return this.halos ? this.halos.physicalSize : 0;
871
874
  }
875
+ _vecHaloClusterCount() {
876
+ return this.halos ? this.halos.clusterCount : 0;
877
+ }
872
878
  _vecHaloCompact() {
873
879
  this.halos.compact();
874
880
  }
@@ -336,6 +336,7 @@ export declare abstract class AbstractStore implements Store {
336
336
  protected abstract _vecContentSize(): number;
337
337
  protected abstract _vecContentLastReads(): number;
338
338
  protected abstract _vecContentPhysicalSize(): number;
339
+ protected abstract _vecContentClusterCount(): number;
339
340
  protected abstract _vecContentCompact(): void;
340
341
  /** Live content-index entries whose INTERNAL id is > `after`, as
341
342
  * {ext, internal} pairs in internal-id order. Internal ids are monotone
@@ -370,7 +371,17 @@ export declare abstract class AbstractStore implements Store {
370
371
  * the tombstone-ratio compaction trigger compares physical size against. */
371
372
  protected abstract _vecHaloSize(): number;
372
373
  protected abstract _vecHaloPhysicalSize(): number;
374
+ protected abstract _vecHaloClusterCount(): number;
373
375
  protected abstract _vecHaloCompact(): void;
376
+ /** Derived query breadth for a partitioned index of C clusters: probe √C
377
+ * of them (the same √-of-the-population convention as the hub bound √N).
378
+ * The IVF maps ef → nprobe as ceil(ef/4), so ef = 4·⌈√C⌉ probes exactly
379
+ * ⌈√C⌉ clusters. A FIXED efSearch stops scaling the moment the
380
+ * collection outgrows it: at 4,270 clusters the default 64 probed 16
381
+ * clusters (0.4%), and an exact stored match of a query routinely sat in
382
+ * an unprobed cluster — recall silently degraded as the store grew. The
383
+ * configured efSearch remains the floor for small collections. */
384
+ protected efFor(clusterCount: number): number;
374
385
  protected _D: number;
375
386
  protected _maxGroup: number;
376
387
  protected readonly minHaloMass: number;
package/dist/src/store.js CHANGED
@@ -359,6 +359,17 @@ function haloDecode(blob, D) {
359
359
  * protected abstract methods that talk to the actual storage backend.
360
360
  */
361
361
  export class AbstractStore {
362
+ /** Derived query breadth for a partitioned index of C clusters: probe √C
363
+ * of them (the same √-of-the-population convention as the hub bound √N).
364
+ * The IVF maps ef → nprobe as ceil(ef/4), so ef = 4·⌈√C⌉ probes exactly
365
+ * ⌈√C⌉ clusters. A FIXED efSearch stops scaling the moment the
366
+ * collection outgrows it: at 4,270 clusters the default 64 probed 16
367
+ * clusters (0.4%), and an exact stored match of a query routinely sat in
368
+ * an unprobed cluster — recall silently degraded as the store grew. The
369
+ * configured efSearch remains the floor for small collections. */
370
+ efFor(clusterCount) {
371
+ return Math.max(this.efSearch, 4 * Math.ceil(Math.sqrt(Math.max(1, clusterCount))));
372
+ }
362
373
  // ── Config ─────────────────────────────────────────────────────────────
363
374
  _D;
364
375
  _maxGroup;
@@ -1144,7 +1155,7 @@ export class AbstractStore {
1144
1155
  if (hit !== undefined)
1145
1156
  return hit;
1146
1157
  }
1147
- const results = this._vecContentQuery(normalize(copy(v)), k * this.overfetch, this.efSearch);
1158
+ const results = this._vecContentQuery(normalize(copy(v)), k * this.overfetch, this.efFor(this._vecContentClusterCount()));
1148
1159
  const out = [];
1149
1160
  for (const r of results) {
1150
1161
  const id = r.id;
@@ -1459,7 +1470,7 @@ export class AbstractStore {
1459
1470
  if (hit !== undefined)
1460
1471
  return hit;
1461
1472
  }
1462
- const results = this._vecHaloQuery(normalize(copy(v)), k * this.overfetch, this.efSearch);
1473
+ const results = this._vecHaloQuery(normalize(copy(v)), k * this.overfetch, this.efFor(this._vecHaloClusterCount()));
1463
1474
  const out = [];
1464
1475
  for (const r of results) {
1465
1476
  const id = r.id;
@@ -1825,9 +1825,22 @@ async function main(): Promise<void> {
1825
1825
 
1826
1826
  /** Run index maintenance: compact (remove garbage) then repair (fill gaps).
1827
1827
  * Both are idempotent — running twice produces the same result as once.
1828
- * Compaction frees index space first; repair then adds back the bridges
1829
- * whose gists were evicted before indexing, completing the coverage that
1830
- * incremental bridge promotion alone cannot guarantee.
1828
+ * Compaction frees index space first; repair then adds back every
1829
+ * edge/halo-bearing node whose gist was evicted from the pending cache
1830
+ * before it reached the content index, completing the coverage that
1831
+ * incremental promotion alone cannot guarantee.
1832
+ *
1833
+ * repair runs with minParents = 0, NOT the library default of 2. The
1834
+ * default repairs only structural BRIDGES (≥2 parents), but this
1835
+ * trainer's fact deposits also leave answer-side DEPOSIT ROOTS with 0
1836
+ * structural parents ("The capital of France is Paris." as the dst of a
1837
+ * Q→A edge is a root of its own tree, contained in nothing). Those are
1838
+ * resonance targets recall depends on — a trained store shipped without
1839
+ * them cannot ground statement-shaped queries against its own answers
1840
+ * (observed: 33 such roots missing after a full curriculum, including
1841
+ * high-traffic conversation replies). minParents = 0 admits every
1842
+ * edge/halo bearer; the candidate set is still corpus-of-experiences-
1843
+ * sized, so the pass stays cheap.
1831
1844
  *
1832
1845
  * Logs the number of entries removed/added so a run that silently degrades
1833
1846
  * (growing compaction count, or repair never recovering anything) is
@@ -1849,10 +1862,12 @@ async function main(): Promise<void> {
1849
1862
  );
1850
1863
  }
1851
1864
  try {
1852
- const added = await mind.repairContentIndex();
1865
+ const added = await mind.repairContentIndex(0);
1853
1866
  if (added > 0) {
1854
1867
  progress.log(
1855
- ` ${GRN}index repair: added ${int(added)} missing bridges${R}`,
1868
+ ` ${GRN}index repair: added ${
1869
+ int(added)
1870
+ } missing resonance targets${R}`,
1856
1871
  );
1857
1872
  }
1858
1873
  } catch (err) {
package/index.html CHANGED
@@ -52,6 +52,10 @@
52
52
  z-index: 0;
53
53
  overflow: hidden;
54
54
  background: var(--bg);
55
+ /* own stacking context + GPU layer: keeps the blend-mode orbs from
56
+ forcing full-page re-rasterization every frame on mobile */
57
+ isolation: isolate;
58
+ transform: translateZ(0);
55
59
  }
56
60
 
57
61
  /* — colour weather: two huge drifting orbs + aurora sweep — */
@@ -60,6 +64,7 @@
60
64
  border-radius: 50%;
61
65
  filter: blur(70px);
62
66
  mix-blend-mode: screen;
67
+ will-change: transform;
63
68
  }
64
69
  .orb.a {
65
70
  width: 75vmax;
@@ -130,6 +135,7 @@
130
135
  transparent 360deg
131
136
  );
132
137
  animation: spin 70s linear infinite;
138
+ will-change: transform;
133
139
  }
134
140
  @keyframes spin {
135
141
  to {
@@ -158,6 +164,7 @@
158
164
  );
159
165
  animation: drift 50s linear infinite alternate;
160
166
  opacity: 0.8;
167
+ will-change: transform;
161
168
  }
162
169
  @keyframes drift {
163
170
  to {
@@ -180,10 +187,12 @@
180
187
  .layerA {
181
188
  animation: sway 30s ease-in-out infinite alternate;
182
189
  transform-origin: 50% 50%;
190
+ will-change: transform;
183
191
  }
184
192
  .layerB {
185
193
  animation: sway 44s ease-in-out infinite alternate-reverse;
186
194
  transform-origin: 50% 50%;
195
+ will-change: transform;
187
196
  }
188
197
  @keyframes sway {
189
198
  from {
@@ -341,6 +350,7 @@
341
350
  opacity: 0.5;
342
351
  animation: spin 40s linear infinite;
343
352
  filter: drop-shadow(0 0 30px rgba(57, 230, 255, 0.3));
353
+ will-change: transform;
344
354
  }
345
355
  .hexring::before {
346
356
  content: "";
@@ -373,6 +383,7 @@
373
383
  transparent 70%
374
384
  );
375
385
  animation: breath 7s ease-in-out infinite;
386
+ will-change: transform, opacity;
376
387
  }
377
388
  @keyframes breath {
378
389
  0%, 100% {
@@ -423,6 +434,11 @@
423
434
  filter: drop-shadow(0 0 22px rgba(57, 230, 255, 0.35)) drop-shadow(
424
435
  0 8px 60px rgba(168, 120, 255, 0.25)
425
436
  );
437
+ /* gradient-clipped text flickers on iOS while its background animates
438
+ unless the element sits on its own composited layer */
439
+ -webkit-backface-visibility: hidden;
440
+ backface-visibility: hidden;
441
+ transform: translateZ(0);
426
442
  }
427
443
  @keyframes sheen {
428
444
  0%, 100% {
@@ -450,6 +466,7 @@
450
466
  color: transparent;
451
467
  -webkit-text-stroke: 1px rgba(57, 230, 255, 0.22);
452
468
  animation: echo 8s ease-in-out infinite;
469
+ will-change: transform, opacity;
453
470
  }
454
471
  @keyframes echo {
455
472
  0%, 100% {
@@ -594,6 +611,7 @@
594
611
  white-space: nowrap;
595
612
  width: max-content;
596
613
  animation: scroll 34s linear infinite;
614
+ will-change: transform;
597
615
  }
598
616
  .ticker span {
599
617
  padding: 0.72em 0;
@@ -652,6 +670,53 @@
652
670
  }
653
671
  }
654
672
 
673
+ /* ——————————————————— mobile / touch performance ——————————————————
674
+ Phones can't sustain the per-frame repaint work: SVG dash/scale
675
+ animations and background-position sheens repaint every frame, and
676
+ each pass runs through blur/drop-shadow filters. Strip the filters
677
+ and the repaint-driven animations; keep every compositor-friendly
678
+ transform/opacity animation so the page stays alive. */
679
+ @media (max-width: 820px), (pointer: coarse) {
680
+ .orb {
681
+ filter: blur(40px);
682
+ }
683
+ .aurora {
684
+ animation: none;
685
+ }
686
+ .grain {
687
+ display: none;
688
+ }
689
+ .wire,
690
+ .wire.v,
691
+ .wire.m,
692
+ .wire.g {
693
+ filter: none;
694
+ animation: none;
695
+ stroke-dasharray: none;
696
+ stroke-width: 1.2;
697
+ opacity: 0.75;
698
+ }
699
+ .layerA,
700
+ .layerB {
701
+ animation: none;
702
+ }
703
+ .ring {
704
+ animation-duration: 7s;
705
+ }
706
+ .hexring {
707
+ filter: none;
708
+ }
709
+ h1 {
710
+ animation: up 1s 0.2s cubic-bezier(0.2, 0.7, 0.2, 1) both;
711
+ background-position: 35% 50%;
712
+ filter: drop-shadow(0 0 22px rgba(57, 230, 255, 0.35));
713
+ }
714
+ .btn.primary {
715
+ animation: none;
716
+ background-position: 50% 50%;
717
+ }
718
+ }
719
+
655
720
  /* ————————————————————————— reduced motion ———————————————————————— */
656
721
  @media (prefers-reduced-motion: reduce) {
657
722
  *, *::before, *::after {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hviana/sema",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Sema: a non-parametric, instance-based reasoning system.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",