insika 0.0.1 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
data/docs/EVALS.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Evals
|
|
3
|
+
parent: Operate & prove it
|
|
4
|
+
nav_order: 4
|
|
5
|
+
permalink: /evals/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Evals
|
|
9
|
+
|
|
10
|
+
An agent has no unit test. The same prompt, the same tools and the same model can give
|
|
11
|
+
a different answer twice, so "does it still work?" cannot be answered by asserting on a
|
|
12
|
+
string. What you can do is keep a small set of **conversations you care about**, replay
|
|
13
|
+
them against a running deployment, and check two different kinds of thing:
|
|
14
|
+
|
|
15
|
+
- **deterministic** — did it call `search_voucher`? did the reply leak a CPF? did a
|
|
16
|
+
tool error? No model involved, no flakiness, no token cost.
|
|
17
|
+
- **subjective** — did it actually resolve the doubt, without inventing a discount?
|
|
18
|
+
That one needs a reader, so a model reads it against a **rubric you write**.
|
|
19
|
+
|
|
20
|
+
That is the whole idea. The rest of this page is the format, who grades, and how a run
|
|
21
|
+
becomes a gate.
|
|
22
|
+
|
|
23
|
+
## A case is data
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
id: loja-chocolates-cupom
|
|
27
|
+
agent: loja-chocolates
|
|
28
|
+
turns:
|
|
29
|
+
- user: "tem algum cupom de desconto ativo?"
|
|
30
|
+
expect:
|
|
31
|
+
tools_called:
|
|
32
|
+
- search_voucher # a trailing "?" marks it optional
|
|
33
|
+
must_not:
|
|
34
|
+
- pii_leak
|
|
35
|
+
- tool_error
|
|
36
|
+
rubric: |
|
|
37
|
+
Consults the active coupon with the tool and says what it is — does NOT invent a
|
|
38
|
+
code or a percentage. If there is none, says so kindly.
|
|
39
|
+
min_score: 0.7
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Turns replay **in order** under one conversation, so a case can build context ("what
|
|
43
|
+
about the shipping?" after "I want the 70% bar"); the assertions run on the last turn.
|
|
44
|
+
|
|
45
|
+
### `requires` — a case that cannot run here is skipped, not failed
|
|
46
|
+
|
|
47
|
+
Deployments differ. Some stores have order tracking wired, some do not; some run
|
|
48
|
+
promotions, some do not. A case asserting `search_orders` is **not a failure** for a
|
|
49
|
+
store without it — it is a case that should never have run, and a red suite nobody
|
|
50
|
+
trusts is worse than a small one.
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
requires:
|
|
54
|
+
tools: [search_orders] # must be available to the agent
|
|
55
|
+
capabilities: [promotions] # facts the operator declared about the deployment
|
|
56
|
+
expect:
|
|
57
|
+
tools_called:
|
|
58
|
+
- search_orders
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The runner asks the deployment what that agent has (`GET /v1/agents/:id`, gated like
|
|
62
|
+
every `/v1` read) **before** spending a turn, and reports a third outcome beside pass
|
|
63
|
+
and fail: **skipped, with the reason**. The rule of thumb: a case that asserts a tool
|
|
64
|
+
should require it.
|
|
65
|
+
|
|
66
|
+
Capabilities are a flat list the operator writes on the agent — inferring "this store
|
|
67
|
+
has promotions" from data is how a suite starts lying:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
declares "promotions", "human_handoff"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Three deliberate edges:
|
|
74
|
+
|
|
75
|
+
- An agent with an **open** tool allowlist runs the case. "I could not rule it out" is
|
|
76
|
+
not a reason to stop testing something.
|
|
77
|
+
- If the deployment cannot be read at all, cases with `requires` **run**, and the CLI
|
|
78
|
+
says so once. A suite must not shrink in silence.
|
|
79
|
+
- The gate never blocks *on* a skip, and `--update-baseline` leaves skipped cases out
|
|
80
|
+
of the baseline entirely. But a case that used to pass here and is now skipped **is**
|
|
81
|
+
reported as a regression: it means the agent lost a tool.
|
|
82
|
+
|
|
83
|
+
### `policy` — how much the agent should ask before acting
|
|
84
|
+
|
|
85
|
+
One optional key, because this is the thing a rubric cannot carry alone. Whether the
|
|
86
|
+
agent should establish the objective before searching, or act on the first plausible
|
|
87
|
+
reading, is a decision **your store** makes — a universal rule would be wrong for half
|
|
88
|
+
of them. Declare it and two things happen: a check that costs nothing runs, and the
|
|
89
|
+
judge is told the rule instead of guessing it.
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
expect:
|
|
93
|
+
policy: ask_once
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| policy | the check | the judge is told |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `ask_once` | no reply asks more than one question | two questions in one message is a failure |
|
|
99
|
+
| `investigate_first` | turn 1 asks something and calls no tool | ask on a vague request, don't search immediately |
|
|
100
|
+
| `act_fast` | turn 1 calls a tool | asking what a search would answer is a failure |
|
|
101
|
+
|
|
102
|
+
Omit it and only the rubric decides. Unlike the other assertions, a policy is checked
|
|
103
|
+
on **every** turn — "one question per reply" is a rule about each reply, and in the
|
|
104
|
+
case that motivated this the violation was on the first one.
|
|
105
|
+
|
|
106
|
+
Question counting is deliberately crude: a run of `?` counts once, and URLs are dropped
|
|
107
|
+
so a tracking link's query string is not read as the agent asking something. It is a
|
|
108
|
+
policy signal, not grammar — and crude was enough to catch an agent breaking a rule
|
|
109
|
+
written in its own prompt, twice, with no model in the loop.
|
|
110
|
+
|
|
111
|
+
### `reference` — compared against the system you want to replace
|
|
112
|
+
|
|
113
|
+
`min_score: 0.7` says a reply cleared a bar you invented. It says nothing about
|
|
114
|
+
whether the system already answering your customers would have done better. If you are
|
|
115
|
+
replacing something, that is the only question that matters — and you have its
|
|
116
|
+
transcripts.
|
|
117
|
+
|
|
118
|
+
Give a case the incumbent's real conversation for the same opening, and a run with
|
|
119
|
+
`--pairwise` asks one judge one question: **which one served the customer better?**
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
reference:
|
|
123
|
+
source: "helpdesk chat 34403117" # free text, so a reader can find the original
|
|
124
|
+
messages:
|
|
125
|
+
- role: user
|
|
126
|
+
text: "tem creatina?"
|
|
127
|
+
- role: assistant
|
|
128
|
+
text: "temos sim! qual seu objetivo?"
|
|
129
|
+
- role: assistant
|
|
130
|
+
text: "segue o link do produto"
|
|
131
|
+
origin: operator # a HUMAN typed this one
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
ruby evals/run.rb --agent loja-chocolates --pairwise
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Three outcomes — `better`, `comparable`, `worse` — and two more the panel can produce
|
|
139
|
+
and the report will not hide: `split` when the judges disagree, `unknown` when none of
|
|
140
|
+
them answered readably. **It never changes pass/fail.** "Worse than the incumbent" is
|
|
141
|
+
an answer about a replacement decision, not a regression in your suite, and it stays
|
|
142
|
+
out of the gate.
|
|
143
|
+
|
|
144
|
+
Three rules make the number worth quoting:
|
|
145
|
+
|
|
146
|
+
- **The judge is not told which one is yours.** It sees "A" and "B". Told, it would
|
|
147
|
+
have an opinion about the new system instead of about the conversations.
|
|
148
|
+
- **Every judge is asked twice, with the transcripts swapped.** Preferring whatever was
|
|
149
|
+
printed first is the classic failure of pairwise grading, so a verdict that flips is
|
|
150
|
+
reported as `comparable`, marked `order-dependent`.
|
|
151
|
+
- **A person is not the incumbent's model.** `origin: operator` on any reference
|
|
152
|
+
message labels the whole pair `vs: human-assisted`, and the summary counts those
|
|
153
|
+
separately — comparing a model to a person and calling it a win is a lie in both
|
|
154
|
+
directions. The judge is not told; the reader is, which is where it changes a
|
|
155
|
+
decision.
|
|
156
|
+
|
|
157
|
+
Cost: **two provider calls per judge per case**, which is why it is opt-in and never
|
|
158
|
+
part of the gate.
|
|
159
|
+
|
|
160
|
+
Cases live in two places, and it is the same YAML in both:
|
|
161
|
+
|
|
162
|
+
- **`evals/golden/**`** in the repo — the curated corpus, reviewable in a pull request,
|
|
163
|
+
and the seed for a fresh deployment.
|
|
164
|
+
- **the store** — what a deployment actually runs, editable in **Studio → Evals**
|
|
165
|
+
without a checkout. That matters because the rubric is the part of an eval a domain
|
|
166
|
+
owner can write, and asking them for a git branch means it never gets written.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
insika evals:import # corpus -> store (a fresh deploy starts here)
|
|
170
|
+
insika evals:import --keep-existing # don't overwrite what was authored in the Studio
|
|
171
|
+
insika evals:export --dir /tmp/cases # store -> YAML, at the paths it came from
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Export refuses to overwrite an existing corpus unless you pass `--force`: `YAML.dump`
|
|
175
|
+
drops the comments those files carry, and each one explains what its case is for.
|
|
176
|
+
|
|
177
|
+
A case whose stored YAML no longer validates is **listed as broken** on the Evals page
|
|
178
|
+
rather than skipped in silence — a test suite that quietly shrinks is worse than a red
|
|
179
|
+
one.
|
|
180
|
+
|
|
181
|
+
## Running
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
ADMIN_TOKEN=… ruby evals/run.rb # cases from the store, else the corpus
|
|
185
|
+
ADMIN_TOKEN=… ruby evals/run.rb --source dir # ignore the store (no database needed)
|
|
186
|
+
ADMIN_TOKEN=… ruby evals/run.rb --agent loja-chocolates --mode both
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
It is **on-demand, not CI**: it costs tokens, needs a live provider key and needs the
|
|
190
|
+
target agents provisioned. `--mode perf` reuses the same replay to report TTFB and
|
|
191
|
+
total latency over real conversations, so one harness answers both questions.
|
|
192
|
+
|
|
193
|
+
A run writes `evals/reports/<timestamp>.json` and prints a markdown summary.
|
|
194
|
+
|
|
195
|
+
## Who grades: a panel, not a voice
|
|
196
|
+
|
|
197
|
+
The judge reads (conversation, reply, rubric) and returns a score in `[0,1]` with one
|
|
198
|
+
sentence of reason. An unparseable judge reply scores **0** — a broken grader must
|
|
199
|
+
never look like a pass.
|
|
200
|
+
|
|
201
|
+
Configure the graders in **Studio → Settings → Evals**, one `provider/model` per line:
|
|
202
|
+
|
|
203
|
+
| Key | Meaning |
|
|
204
|
+
|---|---|
|
|
205
|
+
| `judges` | one entry per model. Empty = deterministic assertions only, and rubric'd cases read as `judge_pending` |
|
|
206
|
+
| `aggregate` | `median` (default), `mean`, or `min` — how the panel's scores become the one number the report and the baseline read |
|
|
207
|
+
| `min_agreement` | fraction of judges that must pass **on their own**. `0.5` = a majority, `1.0` = unanimous |
|
|
208
|
+
| `quorum` | samples per judge, on top of the panel |
|
|
209
|
+
| `tolerance` | max score drop before it counts as a regression |
|
|
210
|
+
|
|
211
|
+
Several models is the point. Sampling **one** model three times measures that model's
|
|
212
|
+
variance — at temperature 0 it mostly returns the same answer, including the same blind
|
|
213
|
+
spot. Two *different* models disagreeing about a rubric is the signal worth having, and
|
|
214
|
+
the report keeps each judge's score so a split panel is visible instead of hidden
|
|
215
|
+
inside an average.
|
|
216
|
+
|
|
217
|
+
`--judge-model` still overrides everything for a one-off run.
|
|
218
|
+
|
|
219
|
+
## The gate
|
|
220
|
+
|
|
221
|
+
A **baseline** (`evals/baseline.json`) is the accepted state of the corpus. A gated run
|
|
222
|
+
blocks only on a **regression** — a case that used to pass and now fails, or a judge
|
|
223
|
+
score that dropped past `tolerance` — so known failures don't wedge the gate while a
|
|
224
|
+
real drop does:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
ruby evals/run.rb --baseline evals/baseline.json # exits non-zero on a regression
|
|
228
|
+
ruby evals/run.rb --update-baseline # accept the current state
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
That is what you run before merging a prompt, tool or model change. A case with no
|
|
232
|
+
baseline entry never blocks: it shows as failing in the report, but a brand-new case is
|
|
233
|
+
not a regression.
|
|
234
|
+
|
|
235
|
+
## Honest limits
|
|
236
|
+
|
|
237
|
+
- **A judge is a model.** It has taste and it has bad days; that is why the
|
|
238
|
+
deterministic layer carries the load and the rubric should be objective ("does not
|
|
239
|
+
invent a code" beats "is friendly").
|
|
240
|
+
- **A green run is not a proof.** It says the cases you wrote still behave. Cases come
|
|
241
|
+
from real conversations — see [Refinement](REFINEMENT.md) for reading production
|
|
242
|
+
traffic back to find the ones worth adding.
|
|
243
|
+
- **The corpus is small on purpose.** Twenty cases covering the hot flows beat two
|
|
244
|
+
hundred nobody curates.
|
|
245
|
+
- **A pairwise verdict is a judgement, not a measurement.** It is one model's opinion
|
|
246
|
+
about two conversations. Read a batch of them by hand before quoting the number in a
|
|
247
|
+
decision — if `better` tracks length or politeness rather than whether the customer
|
|
248
|
+
got served, the comparison is measuring the wrong thing and should be dropped.
|
|
249
|
+
- **A pair the deployment's DATA cannot satisfy is not a loss.** `requires` resolves
|
|
250
|
+
tools and capabilities; it says nothing about catalogue content. If the reference
|
|
251
|
+
conversation found a product that does not exist where you are replaying, no query
|
|
252
|
+
could have returned it, and the pair is unrunnable — exactly like a case needing a
|
|
253
|
+
tool the agent does not have. Exclude it by hand and say so; counting it is the same
|
|
254
|
+
lie `requires` exists to end.
|
|
255
|
+
- **A tool that delivers out of band scores as silence.** When a backend sends the card,
|
|
256
|
+
the button or the support contact straight to the channel, the right behaviour is to
|
|
257
|
+
publish nothing ([`halt_when`](TOOLS.md#halt_when-when-the-answer-is-already-out)) —
|
|
258
|
+
and a comparison that only observes what *you* publish cannot tell that from an agent
|
|
259
|
+
that said nothing. Compare conversations whose tools deliver the same way, or read
|
|
260
|
+
those pairs by hand.
|
|
261
|
+
- **Reuse a conversation and you are judging the wrong transcript.** A replay continues
|
|
262
|
+
whatever the conversation already holds, so a pair replayed into a conversation an
|
|
263
|
+
earlier run touched carries both runs' turns. It shows up as a judge that flips when
|
|
264
|
+
the transcripts are swapped — which is the swap-check earning its keep, but the run
|
|
265
|
+
is spent. Mint fresh conversation ids for every pairwise run.
|
|
266
|
+
|
|
267
|
+
## Where it lives
|
|
268
|
+
|
|
269
|
+
The harness is `lib/insika/evals/*` — inside the engine, because the refinement gate
|
|
270
|
+
needs to score a candidate agent with the same judge, and a second copy of the judge
|
|
271
|
+
would be the worst possible outcome. It stays a **client** even so: it reaches a running
|
|
272
|
+
deployment over HTTP through `POST /v1/responses` and never reads a store directly.
|
|
273
|
+
`evals/run.rb` is a thin CLI over it.
|
data/docs/LOADTEST.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Load test
|
|
3
|
+
parent: Operate & prove it
|
|
4
|
+
nav_order: 3
|
|
5
|
+
permalink: /loadtest/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# LOADTEST — Insika
|
|
9
|
+
|
|
10
|
+
How to load-test the engine, how to compare it **apples-to-apples against another
|
|
11
|
+
gateway**, and how to read the numbers — covering the SQLite-topology question
|
|
12
|
+
(does one box hold up?) and loadtest parity. For deploy/env details see
|
|
13
|
+
[DEPLOY.md](DEPLOY.md).
|
|
14
|
+
|
|
15
|
+
The whole point: the engine exposes `POST /v1/responses` as an **SSE drop-in** of
|
|
16
|
+
the OpenClaw gateway. Same contract → the same load tools work against either side,
|
|
17
|
+
so you can measure the engine you are about to ship against the gateway it replaces.
|
|
18
|
+
|
|
19
|
+
There are three scripts, each answering a different question:
|
|
20
|
+
|
|
21
|
+
| Script | Question it answers | Needs a provider? |
|
|
22
|
+
|--------|---------------------|-------------------|
|
|
23
|
+
| `scripts/bench_store.rb` | Does SQLite (WAL) hold up N processes writing the same file? | No |
|
|
24
|
+
| `scripts/loadtest.rb` | End-to-end: TTFB/total/tokens/cache/error against `/v1/responses` | Yes |
|
|
25
|
+
| `scripts/loadtest-local.sh` | Single-proc baseline vs N-worker multi-proc on one box | Yes |
|
|
26
|
+
| `scripts/loadtest_session.rb` | A full multi-message session (CEP, searches, FAQ) under C concurrent sessions — direct to the engine (`--surface engine`, stream vs steer) or through the consumer's real ingress (`--surface web`, the consumer's widget API) | Yes |
|
|
27
|
+
|
|
28
|
+
All three take `--help` / `-h`.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 1. `bench_store.rb` — SQLite write ceiling (no provider)
|
|
33
|
+
|
|
34
|
+
Isolates "does SQLite survive multi-process?" from LLM-provider noise. N processes
|
|
35
|
+
hammer writes against the **same file** using the engine's real production config
|
|
36
|
+
(WAL + `busy_timeout` + `BEGIN IMMEDIATE` + in-process semaphore). It uses a fresh
|
|
37
|
+
temp db per round, so it never touches your `INSIKA_DB`.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bundle exec ruby scripts/bench_store.rb [PROCS_CSV] [WRITES_PER_PROC]
|
|
41
|
+
# defaults: 1,2,4,8 procs, 2000 writes each
|
|
42
|
+
bundle exec ruby scripts/bench_store.rb 1,2,4,8 3000
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Output columns: `procs | wall(s) | writes/s | p50(ms) | p95(ms) | max(ms) | locked`.
|
|
46
|
+
|
|
47
|
+
**What to look for:** aggregate `writes/s` stays roughly flat as procs grow (the
|
|
48
|
+
WAL "1 writer at a time" ceiling), and `locked` (i.e. `database is locked`) is **0**
|
|
49
|
+
— the `busy_timeout` turns contention into tail latency (`max`), not errors. A real
|
|
50
|
+
turn is provider-bound (seconds) and does only a handful of writes, so the workload
|
|
51
|
+
sits ~100× below this ceiling. See DEPLOY.md for the measured numbers.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 2. `loadtest.rb` — end-to-end against `/v1/responses` (with provider)
|
|
56
|
+
|
|
57
|
+
Hits `POST /v1/responses` (SSE) directly — the production path
|
|
58
|
+
(a consumer app, e.g. WhatsApp, → engine). Standard library only. Fires `agents × concurrency ×
|
|
59
|
+
iterations` turns in waves of `concurrency`, and per turn records TTFB (time to
|
|
60
|
+
first SSE byte), total time, and the `usage` block (tokens + cache hit) of the last
|
|
61
|
+
frame that carries it.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
INSIKA_URL=http://localhost:9292 \
|
|
65
|
+
OPENCLAW_GATEWAY_TOKEN=xxx \
|
|
66
|
+
bundle exec ruby scripts/loadtest.rb \
|
|
67
|
+
--agents bia,my-store --concurrency 16 --iterations 3 \
|
|
68
|
+
--message "hi, how are you?"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Runs against a local server **or** a remote one (e.g. Railway) — just point
|
|
72
|
+
`INSIKA_URL` at it.
|
|
73
|
+
|
|
74
|
+
### Flags
|
|
75
|
+
|
|
76
|
+
| Flag | Default | Meaning |
|
|
77
|
+
|------|---------|---------|
|
|
78
|
+
| `--agents a,b,c` | `bia` | comma-separated agent ids (mapped to `model: openclaw:<agent>`) |
|
|
79
|
+
| `--concurrency N` | `8` | concurrent turns per wave |
|
|
80
|
+
| `--iterations N` | `1` | number of waves per agent |
|
|
81
|
+
| `--message TEXT` | greeting | user message sent every turn |
|
|
82
|
+
| `--timeout SECONDS` | `120` | per-request read timeout |
|
|
83
|
+
| `--ports 9292,9293` | — | round-robin across local processes (see §4) |
|
|
84
|
+
| `--same-user` | off | boolean toggle — reuse the same `user` per agent to measure the **hot-conversation cache** (legacy `--same-user 1` / `--same-user 0` still work) |
|
|
85
|
+
| `--dry-run` | — | print the plan + one sample request (masked token) and exit; **sends no traffic** |
|
|
86
|
+
| `--help` | — | show usage and exit |
|
|
87
|
+
|
|
88
|
+
### Environment
|
|
89
|
+
|
|
90
|
+
| Env | Default | Meaning |
|
|
91
|
+
|-----|---------|---------|
|
|
92
|
+
| `INSIKA_URL` | `http://localhost:9292` | base URL of the engine |
|
|
93
|
+
| `OPENCLAW_GATEWAY_TOKEN` | falls back to `ADMIN_TOKEN`, then `local-demo` | Bearer for `/v1/responses` |
|
|
94
|
+
| `DEEPSEEK_API_KEY` | — | must be configured **on the server** for real turns (not read by the client) |
|
|
95
|
+
|
|
96
|
+
Use `--dry-run` to sanity-check your flags/URL/token before firing real traffic
|
|
97
|
+
(and to confirm the request body without needing a running server):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
INSIKA_URL=http://localhost:9292 OPENCLAW_GATEWAY_TOKEN=xxx \
|
|
101
|
+
bundle exec ruby scripts/loadtest.rb --agents bia --concurrency 16 --dry-run
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `--same-user` and the cache
|
|
105
|
+
|
|
106
|
+
By default each turn uses a distinct `user` (`loadtest-<agent>-<idx>`), so every
|
|
107
|
+
turn is a cold conversation. With `--same-user` all turns for an agent share one
|
|
108
|
+
`user`, exercising the warm-conversation path — watch the **mean cache hit** rise
|
|
109
|
+
and TTFB drop. Run both to bracket cold vs hot behaviour.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 3. `loadtest-local.sh` — baseline vs multi-worker on one box
|
|
114
|
+
|
|
115
|
+
Boots Falcon with `--count 1` (single-process baseline), runs the sweep, then boots
|
|
116
|
+
`--count N` (multi-process) over the **same** SQLite file (WAL), runs the sweep
|
|
117
|
+
again, and counts `database is locked` in each Falcon log. Since the provider is
|
|
118
|
+
identical across both runs, if multi-proc recovers throughput the ceiling was
|
|
119
|
+
CPU/event-loop (not the provider), and a `locked` count of **0** proves the WAL +
|
|
120
|
+
`busy_timeout` config absorbs cross-process write contention.
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
DEEPSEEK_API_KEY=sk-... ./scripts/loadtest-local.sh [WORKERS] [CONCURRENCY]
|
|
124
|
+
# defaults: 4 workers, 16 concurrency
|
|
125
|
+
./scripts/loadtest-local.sh 4 24
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| Env | Default | Meaning |
|
|
129
|
+
|-----|---------|---------|
|
|
130
|
+
| `DEEPSEEK_API_KEY` | — (required) | real turns hit the provider; also auto-sourced from `.env.local` |
|
|
131
|
+
| `OPENCLAW_GATEWAY_TOKEN` | falls back to `ADMIN_TOKEN`, then `local-demo` | Bearer for the sweep |
|
|
132
|
+
| `PORT` | `9299` | bind port for the local Falcon |
|
|
133
|
+
| `AGENT` | `bia` | agent id to load |
|
|
134
|
+
|
|
135
|
+
The final block prints the lock counts; the expected reading is `0` for both:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
=== 'database is locked' (expected 0 — WAL + busy_timeout) ===
|
|
139
|
+
baseline (1): 0
|
|
140
|
+
multi (4): 0
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 4. Apples-to-apples: Insika vs the OpenClaw gateway
|
|
146
|
+
|
|
147
|
+
Two ways to compare, both valid because the SSE contract is identical.
|
|
148
|
+
|
|
149
|
+
### 4a. Ruby native (`loadtest.rb`) against both
|
|
150
|
+
|
|
151
|
+
Run the same `loadtest.rb` invocation twice — once with `INSIKA_URL` pointing at
|
|
152
|
+
the engine, once at the gateway (its `/v1/responses` speaks the same protocol).
|
|
153
|
+
Keep `--agents`, `--concurrency`, `--iterations` and `--message` identical, and use
|
|
154
|
+
matching agents on both sides. Compare the printed TTFB/total/cache/error lines.
|
|
155
|
+
|
|
156
|
+
### 4b. Reuse OpenClaw's `loadtest-gateway.mjs` unmodified
|
|
157
|
+
|
|
158
|
+
`loadtest.rb` is the Ruby port of OpenClaw's `loadtest-gateway.mjs`. You do **not**
|
|
159
|
+
need to change that script to point it at the engine — because the engine is a
|
|
160
|
+
drop-in for the gateway, you only change **where it points**:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# In the OpenClaw checkout, run its gateway loadtest against the HARNESS:
|
|
164
|
+
OPENCLAW_GATEWAY_URL=http://localhost:9292 \
|
|
165
|
+
OPENCLAW_GATEWAY_TOKEN=<same bearer the engine accepts> \
|
|
166
|
+
node scripts/loadtest-gateway.mjs --agents bia --concurrency 16 --iterations 3
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Then run the exact same command with `OPENCLAW_GATEWAY_URL` pointing at the real
|
|
170
|
+
gateway, and diff the two reports. This is the shadow comparison the pilot needs.
|
|
171
|
+
|
|
172
|
+
**What the operator must have in hand** (this repo does not vendor OpenClaw):
|
|
173
|
+
|
|
174
|
+
- The OpenClaw checkout containing `scripts/loadtest-gateway.mjs` and Node installed.
|
|
175
|
+
- A **bearer token accepted by both** sides. For the engine that is
|
|
176
|
+
`OPENCLAW_GATEWAY_TOKEN` (see DEPLOY.md); point the gateway run at its own token.
|
|
177
|
+
- **The same agent id provisioned on both** sides (e.g. `bia`) so `model:
|
|
178
|
+
openclaw:<agent>` resolves on each. On the engine, provision via
|
|
179
|
+
`scripts/import_pack.rb`.
|
|
180
|
+
- The **same provider** (or an equivalent-latency one) behind each, otherwise you
|
|
181
|
+
are comparing providers, not engines.
|
|
182
|
+
- Both endpoints reachable from where you run the client, warmed up (hit `/up` on
|
|
183
|
+
the engine first), and ideally driven from the same machine to remove network
|
|
184
|
+
skew.
|
|
185
|
+
|
|
186
|
+
Keep every knob identical between the two runs — the only variable should be which
|
|
187
|
+
engine is behind `/v1/responses`.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## 5. Reading the metrics
|
|
192
|
+
|
|
193
|
+
- **TTFB** (time to first SSE byte) — how fast the user starts seeing a response.
|
|
194
|
+
Dominated by provider latency + the engine's per-turn setup (context build,
|
|
195
|
+
policy, first model call). This is the number that most shapes perceived latency.
|
|
196
|
+
- **total** — full turn wall time including the whole tool-loop and streamed
|
|
197
|
+
output. `total − TTFB` is roughly the streaming/tool-loop tail.
|
|
198
|
+
- **P50 vs P95** — P50 is the typical turn; **P95 is the tail you actually feel**
|
|
199
|
+
under load. A P50 that stays flat while P95 balloons as concurrency rises means
|
|
200
|
+
you are queueing (CPU/event-loop or writer contention) — that is the signal to
|
|
201
|
+
add workers (§3) or check `bench_store.rb`.
|
|
202
|
+
- **mean tokens** — average `total_tokens`/`output_tokens` per turn; sanity-checks
|
|
203
|
+
that turns did real work and lets you compare cost between runs/engines.
|
|
204
|
+
- **mean cache hit** — average cached prompt tokens; should rise sharply with
|
|
205
|
+
`--same-user 1`. A high hit rate is why warm conversations are cheaper and faster.
|
|
206
|
+
- **error rate** (`turns ok: X/Y (errors: N)`) — non-2xx, timeouts, or connection
|
|
207
|
+
failures. Anything above ~0 under moderate load is a red flag; inspect server
|
|
208
|
+
logs. Note `loadtest.rb` errors are transport/HTTP-level; `database is locked`
|
|
209
|
+
specifically is counted by `loadtest-local.sh` from the Falcon logs, not here.
|
|
210
|
+
- **throughput** (`turns/s`) — completed turns per wall second; the headline
|
|
211
|
+
capacity number for a given concurrency.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 6. Checklist — what to measure before choosing a topology
|
|
216
|
+
|
|
217
|
+
Work top-down and **measure before assuming** — avoid premature topology optimization.
|
|
218
|
+
|
|
219
|
+
| # | Measure | Tool | Decision it informs |
|
|
220
|
+
|---|---------|------|---------------------|
|
|
221
|
+
| 1 | SQLite write ceiling & `locked` count under N procs | `bench_store.rb` | Is SQLite a bottleneck at all on one box? (Expected: no.) |
|
|
222
|
+
| 2 | Single-proc baseline TTFB/total/P95/throughput | `loadtest.rb` (or `loadtest-local.sh` count 1) | The reference point for everything else. |
|
|
223
|
+
| 3 | Multi-proc on one box: does throughput scale, `locked` = 0? | `loadtest-local.sh` | Do more Falcon workers help, and does the shared WAL hold? Sets `WEB_CONCURRENCY`. |
|
|
224
|
+
| 4 | Insika vs gateway, identical knobs | §4 (either method) | Is the engine at parity with the engine it replaces before cut-over? |
|
|
225
|
+
| 5 | Cold vs hot conversation (cache) | `loadtest.rb` with/without `--same-user 1` | Expected steady-state cost/latency once conversations warm up. |
|
|
226
|
+
| 6 | Remote (Railway) vs local | `loadtest.rb` with `INSIKA_URL` remote | Network/deploy overhead of the real environment. |
|
|
227
|
+
|
|
228
|
+
**Reaching for horizontal scale is only justified after 1–3 show the single box is
|
|
229
|
+
the limit.** If it is, the paths are: sharding-by-tenant +
|
|
230
|
+
sticky routing (recommended), LiteFS, or an optional Postgres adapter — plus
|
|
231
|
+
Litestream for backup/DR regardless of topology. Do not skip straight to Postgres:
|
|
232
|
+
the numbers usually show SQLite on one big box is not the bottleneck.
|