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
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class InMemoryModelCatalog : ModelCatalog {
// 6-9 GB RAM tier. Devices under 6 GB should be surfaced to the
// user as "not supported" rather than attempting a failing init.
minDeviceRamMb = 6000,
supportsCharts = true,
),
ModelDescriptor(
id = "gemma-4-e4b-it-litertlm",
Expand All @@ -46,6 +47,7 @@ class InMemoryModelCatalog : ModelCatalog {
role = ModelRole.LLM_PRIMARY,
// 10+ GB RAM tier — flagship devices get the larger E4B variant.
minDeviceRamMb = 10000,
supportsCharts = true,
),
ModelDescriptor(
id = "universal-sentence-encoder",
Expand Down
8 changes: 8 additions & 0 deletions lib/src/commonMain/kotlin/com/sagar/aicore/ModelCatalog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ data class ModelDescriptor(
* skipping it on text-only models also frees memory. Defaults to false.
*/
val supportsVision: Boolean = false,
/**
* Whether this model reliably follows the optional chart-emitting instruction
* (see `chart.ChartInstruction`). Tiny models (≲1B) tend to parrot the in-prompt
* chart examples into unrelated answers instead of honoring the "only when it
* genuinely helps" guardrail, so consumers should append the chart instruction
* to the system prompt ONLY when this is true. Defaults to false (off).
*/
val supportsCharts: Boolean = false,
)

enum class ModelFormat {
Expand Down
55 changes: 53 additions & 2 deletions lib/src/commonMain/kotlin/com/sagar/aicore/chart/ChartParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ import kotlinx.serialization.json.doubleOrNull
object ChartParser {

fun parse(json: String): ChartSpec? = runCatching {
val obj = Json.parseToJsonElement(json.trim()) as? JsonObject ?: return@runCatching null
val type = str(obj["type"])?.lowercase()?.trim()
val root = Json.parseToJsonElement(json.trim()) as? JsonObject ?: return@runCatching null
// Resolve the chart object + its type. The strict top-level "type" path is
// tried FIRST (so well-formed `{"type":"donut",…}` is unchanged), then the
// tolerant fallbacks for models that wrap the chart or omit the type.
val (obj, type) = resolve(root) ?: return@runCatching null
val title = str(obj["title"])?.ifBlank { null }
when (type) {
"donut", "doughnut", "pie" -> {
Expand All @@ -51,6 +54,54 @@ object ChartParser {
}
}.getOrNull()

// ── chart resolution (tolerant of how different models shape the JSON) ──

/** Wrapper keys some models nest the chart under, mapped to a type hint
* (empty = generic wrapper, infer the type from the inner object). */
private val WRAPPERS = mapOf(
"donut" to "donut", "doughnut" to "donut", "pie" to "donut",
"bar" to "bar", "barchart" to "bar", "column" to "bar", "columns" to "bar", "histogram" to "bar",
"line" to "line", "linechart" to "line", "area" to "line", "trend" to "line", "growth" to "line", "spline" to "line",
"progress" to "progress", "gauge" to "progress", "ring" to "progress",
"chart" to "", "graph" to "", "plot" to "", "visualization" to "", "visualisation" to "",
)

/** Reads an explicit type, tolerating `chartType` / `kind` synonyms. */
private fun typeOf(obj: JsonObject): String? =
(str(obj["type"]) ?: str(obj["chartType"]) ?: str(obj["kind"]))?.lowercase()?.trim()?.ifBlank { null }

/** Whether an object carries chart-shaped payload (so a bare key isn't mistaken for a chart). */
private fun hasData(obj: JsonObject): Boolean =
obj["data"] is JsonArray || obj["slices"] is JsonArray || obj["values"] is JsonArray ||
obj["series"] is JsonArray || obj["value"] != null

/** When the type is absent, infer it from the payload shape (proportions → donut). */
private fun inferType(obj: JsonObject): String? = when {
obj["series"] is JsonArray -> "line"
obj["data"] is JsonArray || obj["slices"] is JsonArray || obj["values"] is JsonArray -> "donut"
obj["value"] != null -> "progress"
else -> null
}

/**
* Resolves the (object, type) to read the chart from:
* 1) explicit type on the root (the strict, common case — unchanged);
* 2) the chart nested under a single wrapper key (`{"chart":{…}}`, `{"donut":{…}}`),
* taking the type from the wrapper name, else the inner type, else inferred;
* otherwise null (ordinary JSON stays a code block).
*/
private fun resolve(root: JsonObject): Pair<JsonObject, String>? {
typeOf(root)?.let { return root to it }
for ((key, value) in root) {
val inner = value as? JsonObject ?: continue
val hint = WRAPPERS[key.lowercase().trim()] ?: continue
if (!hasData(inner)) continue
val type = hint.ifBlank { null } ?: typeOf(inner) ?: inferType(inner) ?: continue
return inner to type
}
return null
}

// ── helpers ──

/** A flat list of labelled values under "data" / "slices" / "values". */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,31 @@ class ChartParserTest {
assertTrue(bar.bars.map { it.label } == listOf("A", "B"))
}

@Test fun unwrapsChartWrapperWithoutType() {
// DeepSeek-R1 1.5B shape: nested under "chart", no "type", proportional data → donut.
val spec = ChartParser.parse(
"""{"chart":{"name":"Budget Breakdown","title":"Monthly Budget","data":[{"label":"Rent","value":40},{"label":"Food","value":25}]}}""",
)
val donut = assertIs<ChartSpec.Donut>(spec)
assertEquals("Monthly Budget", donut.title)
assertEquals(2, donut.slices.size)
}

@Test fun typeFromWrapperKey() {
assertIs<ChartSpec.Bar>(ChartParser.parse("""{"bar":{"data":[{"label":"A","value":1},{"label":"B","value":2}]}}"""))
assertIs<ChartSpec.Donut>(ChartParser.parse("""{"pie":{"data":[{"label":"A","value":1},{"label":"B","value":2}]}}"""))
}

@Test fun acceptsChartTypeSynonymKey() {
assertIs<ChartSpec.Bar>(ChartParser.parse("""{"chartType":"bar","data":[{"label":"A","value":1}]}"""))
}

@Test fun bareUntypedJsonStaysNull() {
// No type and no wrapper → ordinary JSON, must remain a code block.
assertNull(ChartParser.parse("""{"data":[{"label":"A","value":1},{"label":"B","value":2}]}"""))
assertNull(ChartParser.parse("""{"foo":1,"bar":[1,2,3]}"""))
}

// kotlin.test has no assertIs in older versions on all targets; provide a tiny helper.
private inline fun <reified T> assertIs(value: Any?): T {
assertTrue(value is T, "expected ${T::class.simpleName} but was ${value?.let { it::class.simpleName }}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class NativeLmModelCatalog : ModelCatalog {
minDeviceRamMb = 6000,
requiresAuth = false,
supportsVision = false,
supportsCharts = true,
),
// ── Mid+ (~7 GB) — Gemma 4 E2B, multimodal (~2.6 GB). 7000 excludes 6 GB
// phones (which report ~5.9 GB) while keeping it on genuine 8 GB+ hardware. ──
Expand All @@ -75,6 +76,7 @@ class NativeLmModelCatalog : ModelCatalog {
minDeviceRamMb = 7000,
requiresAuth = true,
supportsVision = true,
supportsCharts = true,
),
// ── High (~10 GB) — Gemma 4 E4B, multimodal (~3.7 GB). ──
ModelDescriptor(
Expand All @@ -87,6 +89,7 @@ class NativeLmModelCatalog : ModelCatalog {
minDeviceRamMb = 10000,
requiresAuth = true,
supportsVision = true,
supportsCharts = true,
),
// ── High (~10 GB) — Phi-4-mini, q8, text reasoning (~3.9 GB), ungated
// (MIT). A non-Gemma high-tier option alongside E4B. ──
Expand All @@ -100,6 +103,7 @@ class NativeLmModelCatalog : ModelCatalog {
minDeviceRamMb = 10000,
requiresAuth = false,
supportsVision = false,
supportsCharts = true,
),
// ── Flagship (~12 GB+) — Qwen3 4B, channelwise int8, text (~5.3 GB),
// ungated (Apache-2.0). NOTE: reachable only on devices whose effective
Expand All @@ -116,6 +120,7 @@ class NativeLmModelCatalog : ModelCatalog {
minDeviceRamMb = 12000,
requiresAuth = false,
supportsVision = false,
supportsCharts = true,
),
ModelDescriptor(
id = "universal-sentence-encoder",
Expand Down
19 changes: 15 additions & 4 deletions sample-app/src/main/java/com/nativelm/app/llm/NativeLmViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,8 @@ import java.io.File

private const val TAG = "NativeLmVM"

private val SYSTEM_INSTRUCTION =
"You are NativeLM, a helpful on-device assistant. Answer clearly and concisely.\n\n" +
ChartInstruction.SYSTEM
private const val BASE_SYSTEM_INSTRUCTION =
"You are NativeLM, a helpful on-device assistant. Answer clearly and concisely."

const val ROUTE_SPLASH = "splash"
const val ROUTE_ONBOARDING = "onboarding"
Expand Down Expand Up @@ -772,6 +771,18 @@ class NativeLmViewModel(app: Application) : ViewModel() {
}
}

/**
* The system instruction for the active model. The chart-emitting fragment is
* appended ONLY for models flagged [ModelDescriptor.supportsCharts]; tiny models
* (e.g. Qwen3 0.6B) parrot the chart examples into unrelated answers, so they
* chat with the plain instruction instead.
*/
private fun systemInstruction(): String {
val supportsCharts = _activeModelId.value?.let { catalog.byId(it) }?.supportsCharts == true
return if (supportsCharts) "$BASE_SYSTEM_INSTRUCTION\n\n${ChartInstruction.SYSTEM}"
else BASE_SYSTEM_INSTRUCTION
}

/**
* Open a stateful chat session, seeding it with [history] (re-prefilled once,
* surfaced as [ChatState.isWarming]). Closes any prior session. The KV cache
Expand All @@ -785,7 +796,7 @@ class NativeLmViewModel(app: Application) : ViewModel() {
text = it.text,
)
}
val session = engineHolder.openChatSession(turns, SYSTEM_INSTRUCTION)
val session = engineHolder.openChatSession(turns, systemInstruction())
chatSession = session
if (!showWarming) {
_chat.update { it.copy(isWarming = false) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ class NativeLmModelCatalogTest {
assertTrue("entry is the smallest LLM tier", m.minDeviceRamMb <= 4000L)
}

@Test fun chartsGatedToCapableModelsOnly() {
// Tiny models parrot the chart examples into unrelated answers, so the
// chart instruction is opt-in: the entry Qwen3 0.6B and Gemma 3 1B are OFF;
// the 1.5B-class and larger are ON.
assertFalse("Qwen3 0.6B too small for charts", catalog.byId("qwen3-0_6b-litertlm")!!.supportsCharts)
assertFalse("Gemma 3 1B too small for charts", catalog.byId("gemma3-1b-it-int4-litertlm")!!.supportsCharts)
assertTrue("DeepSeek 1.5B charts", catalog.byId("deepseek-r1-distill-qwen-1_5b-litertlm")!!.supportsCharts)
assertTrue("Gemma 4 E2B charts", catalog.byId("gemma-4-e2b-it-litertlm")!!.supportsCharts)
assertTrue("Phi-4 mini charts", catalog.byId("phi-4-mini-instruct-litertlm")!!.supportsCharts)
assertTrue("Qwen3 4B charts", catalog.byId("qwen3-4b-litertlm")!!.supportsCharts)
}

@Test fun catalogueSpansEntryToFlagship() {
val llms = catalog.byRole(ModelRole.LLM_PRIMARY)
// A real cross-device range: many tiers, all .litertlm, a mix of gated +
Expand Down
Loading