dataface 0.3.2.dev22__py3-none-any.whl → 0.3.2.dev111__py3-none-any.whl

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.
Files changed (185) hide show
  1. dataface/__init__.py +70 -23
  2. dataface/agent_api/__init__.py +162 -68
  3. dataface/agent_api/_paths.py +9 -3
  4. dataface/agent_api/docs/yaml-reference.md +240 -106
  5. dataface/agent_api/init.py +1 -1
  6. dataface/agent_api/project_session.py +2 -1
  7. dataface/agent_api/serve.py +115 -0
  8. dataface/ai/agent.py +28 -2
  9. dataface/cli/_project.py +100 -1
  10. dataface/cli/commands/board_artifact.py +5 -2
  11. dataface/cli/commands/describe.py +7 -3
  12. dataface/cli/commands/docs.py +12 -3
  13. dataface/cli/commands/init.py +7 -14
  14. dataface/cli/commands/inspect.py +6 -1
  15. dataface/cli/commands/mcp_init.py +23 -43
  16. dataface/cli/commands/migrate.py +8 -3
  17. dataface/cli/commands/query.py +11 -8
  18. dataface/cli/commands/render.py +20 -9
  19. dataface/cli/commands/search.py +8 -2
  20. dataface/cli/commands/serve.py +26 -76
  21. dataface/cli/commands/skills_init.py +2 -19
  22. dataface/cli/commands/validate.py +7 -2
  23. dataface/cli/filesystem_project.py +5 -4
  24. dataface/cli/main.py +2 -19
  25. dataface/core/__init__.py +73 -28
  26. dataface/core/{compile/colors.py → colors.py} +20 -5
  27. dataface/core/compile/compiler.py +97 -34
  28. dataface/core/compile/data_table.py +7 -2
  29. dataface/core/compile/enrich.py +18 -44
  30. dataface/core/compile/face_warnings.py +46 -8
  31. dataface/core/compile/format.py +47 -1
  32. dataface/core/compile/inherit_graph.py +3 -3
  33. dataface/core/compile/inherit_registry.yaml +1 -1
  34. dataface/core/compile/migrations.py +112 -9
  35. dataface/core/compile/models/chart/normalized/kpi.py +0 -4
  36. dataface/core/compile/models/chart/resolved/_base.py +28 -10
  37. dataface/core/compile/models/chart/resolved/callout.py +2 -3
  38. dataface/core/compile/models/chart/resolved/kpi.py +7 -3
  39. dataface/core/compile/models/chart/resolved/table.py +16 -2
  40. dataface/core/compile/models/markers.py +1 -1
  41. dataface/core/compile/models/primitives.py +175 -2
  42. dataface/core/compile/models/source.py +5 -5
  43. dataface/core/compile/models/style/authored/__init__.py +32 -8
  44. dataface/core/compile/models/style/authored/_base.py +126 -69
  45. dataface/core/compile/models/style/authored/table.py +61 -3
  46. dataface/core/compile/models/style/resolved/__init__.py +12 -2
  47. dataface/core/compile/models/style/resolved/_base.py +142 -57
  48. dataface/core/compile/models/style/resolved/_cartesian.py +6 -11
  49. dataface/core/compile/models/style/resolved/_cascade.py +141 -54
  50. dataface/core/compile/models/style/resolved/area.py +1 -1
  51. dataface/core/compile/models/style/resolved/bar.py +1 -1
  52. dataface/core/compile/models/style/resolved/line.py +1 -1
  53. dataface/core/compile/models/style/resolved/scatter.py +3 -2
  54. dataface/core/compile/models/style/theme/__init__.py +36 -14
  55. dataface/core/compile/models/style/theme/_chart_base.py +17 -9
  56. dataface/core/compile/models/style/theme/axis.py +432 -312
  57. dataface/core/compile/models/style/theme/charts.py +10 -6
  58. dataface/core/compile/models/style/theme/kpi.py +2 -1
  59. dataface/core/compile/models/style/theme/page.py +5 -2
  60. dataface/core/compile/models/style/theme/table.py +2 -2
  61. dataface/core/compile/models/vega_lite/config.py +1 -1
  62. dataface/core/compile/normalize_charts.py +6 -2
  63. dataface/core/compile/normalize_queries.py +21 -7
  64. dataface/core/compile/resolve/__init__.py +2 -0
  65. dataface/core/compile/resolve/_axes.py +147 -44
  66. dataface/core/compile/resolve/_channels.py +1 -1
  67. dataface/core/compile/resolve/_dispatch.py +1 -1
  68. dataface/core/compile/resolve/_domain.py +49 -32
  69. dataface/core/compile/resolve/_kwargs.py +39 -12
  70. dataface/core/compile/resolve/_marks.py +8 -3
  71. dataface/core/compile/resolve/_palette.py +7 -3
  72. dataface/core/compile/resolve/_table.py +124 -33
  73. dataface/core/compile/resolve/area.py +48 -18
  74. dataface/core/compile/resolve/bar.py +245 -64
  75. dataface/core/compile/resolve/geo.py +10 -3
  76. dataface/core/compile/resolve/heatmap.py +16 -8
  77. dataface/core/compile/resolve/line.py +35 -12
  78. dataface/core/compile/resolve/pie.py +4 -1
  79. dataface/core/compile/resolve/pie_attachment.py +1 -1
  80. dataface/core/compile/resolve/scatter.py +25 -13
  81. dataface/core/compile/resolve/simple.py +25 -2
  82. dataface/core/compile/schema_renderers/prompt.py +1 -1
  83. dataface/core/compile/schema_renderers/textmate_grammar.py +8 -1
  84. dataface/core/compile/source_map.py +211 -28
  85. dataface/core/compile/style_cascade.py +126 -55
  86. dataface/core/compile/yaml_error_formatter.py +1 -1
  87. dataface/core/connections.py +10 -3
  88. dataface/core/dashboard.py +24 -18
  89. dataface/core/defaults/themes/_base.yaml +49 -39
  90. dataface/core/defaults/themes/cream.yaml +4 -4
  91. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +1 -1
  92. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +1 -1
  93. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +1 -1
  94. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +1 -1
  95. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +1 -1
  96. dataface/core/defaults/themes/editorial.yaml +6 -6
  97. dataface/core/defaults/themes/neon.yaml +15 -8
  98. dataface/core/defaults/themes/plain.yaml +6 -4
  99. dataface/core/defaults/themes/stark.yaml +16 -7
  100. dataface/core/defaults/themes/vivid.yaml +15 -8
  101. dataface/core/diagnostics/__init__.py +2 -2
  102. dataface/core/diagnostics/codes_compile.py +13 -31
  103. dataface/core/diagnostics/codes_execute.py +79 -0
  104. dataface/core/diagnostics/codes_render.py +87 -28
  105. dataface/core/diagnostics/diagnostic.py +30 -0
  106. dataface/core/diagnostics/hints.py +19 -0
  107. dataface/core/dialects/base.py +17 -0
  108. dataface/core/dialects/postgres.py +14 -0
  109. dataface/core/dialects/redshift.py +5 -0
  110. dataface/core/execute/__init__.py +59 -14
  111. dataface/core/execute/adapters/dbt_adapter.py +4 -1
  112. dataface/core/execute/adapters/dbt_adapter_factory.py +19 -0
  113. dataface/core/execute/adapters/dbt_utils.py +84 -20
  114. dataface/core/execute/adapters/postgres_connection_manager.py +48 -0
  115. dataface/core/execute/adapters/sql_adapter.py +72 -4
  116. dataface/core/execute/dbt_jinja.py +65 -10
  117. dataface/core/execute/executor.py +69 -16
  118. dataface/core/font_measure.py +28 -0
  119. dataface/core/fonts.py +8 -5
  120. dataface/core/inspect/__init__.py +46 -6
  121. dataface/core/project.py +13 -1
  122. dataface/core/render/chart/data_table_attachment.py +14 -12
  123. dataface/core/render/chart/emitters/_cartesian.py +21 -18
  124. dataface/core/render/chart/emitters/_channels.py +56 -8
  125. dataface/core/render/chart/emitters/_label_overlap.py +18 -15
  126. dataface/core/render/chart/emitters/_measured_label_padding.py +4 -4
  127. dataface/core/render/chart/emitters/area.py +3 -3
  128. dataface/core/render/chart/emitters/bar.py +60 -35
  129. dataface/core/render/chart/emitters/geo.py +8 -13
  130. dataface/core/render/chart/emitters/heatmap.py +20 -26
  131. dataface/core/render/chart/emitters/line.py +2 -2
  132. dataface/core/render/chart/emitters/scatter.py +7 -30
  133. dataface/core/render/chart/features/baseline.py +22 -9
  134. dataface/core/render/chart/features/endpoint_labels.py +55 -32
  135. dataface/core/render/chart/features/mirror_axis.py +12 -9
  136. dataface/core/render/chart/features/value_labels.py +1 -1
  137. dataface/core/render/chart/kpi.py +11 -90
  138. dataface/core/render/chart/presentation.py +6 -8
  139. dataface/core/render/chart/rendering.py +3 -12
  140. dataface/core/render/chart/session.py +10 -0
  141. dataface/core/render/chart/spec.py +3 -0
  142. dataface/core/render/chart/spec_builders.py +18 -18
  143. dataface/core/render/chart/table.py +81 -36
  144. dataface/core/render/chart/table_support.py +2 -53
  145. dataface/core/render/chart/time_unit_detect.py +3 -2
  146. dataface/core/render/chart/translate.py +82 -44
  147. dataface/core/render/chart/type_inference.py +45 -25
  148. dataface/core/render/chart/validation.py +91 -0
  149. dataface/core/render/chart/vega_lite.py +30 -11
  150. dataface/core/render/chart/vl_field_maps.py +111 -76
  151. dataface/core/render/converters/chart.py +16 -6
  152. dataface/core/render/faces.py +3 -2
  153. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.woff2 +0 -0
  154. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.woff2 +0 -0
  155. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -93
  156. dataface/core/render/fonts/_font_face.css +10 -0
  157. dataface/core/render/layout_sizing.py +267 -238
  158. dataface/core/render/numeral_expr.py +80 -0
  159. dataface/core/render/warnings/bar_band_width_too_narrow.py +64 -6
  160. dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +25 -3
  161. dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +12 -6
  162. dataface/core/render/warnings/palette_unsupported.py +6 -0
  163. dataface/core/render/warnings/pie_dominant_segment.py +1 -0
  164. dataface/core/render/warnings/pie_too_many_segments.py +1 -0
  165. dataface/core/render/warnings/point_map_out_of_projection.py +1 -0
  166. dataface/core/render/warnings/query_returned_zero_rows.py +1 -0
  167. dataface/core/render/warnings/redundant_encoding.py +20 -1
  168. dataface/core/render/warnings/registry.py +7 -3
  169. dataface/core/render/warnings/temporal_single_point.py +1 -0
  170. dataface/core/render/warnings/too_many_color_categories.py +1 -0
  171. dataface/core/render/warnings/too_many_x_categories.py +18 -2
  172. dataface/core/render/warnings/value_labels_crowd_width.py +9 -0
  173. dataface/core/render/warnings/y_encoding_mostly_null.py +1 -0
  174. dataface/core/render_format.py +18 -0
  175. dataface/core/text/format_d3.py +56 -0
  176. dataface/core/text/numeral_scale.py +218 -0
  177. dataface/core/utils.py +103 -10
  178. dataface/data/schemas/board-resolved/board-resolved.schema.json +9487 -7978
  179. dataface/data/schemas/yaml/0.3.2.json +1 -0
  180. dataface/data/schemas/yaml/manifest.json +7 -0
  181. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/METADATA +1 -1
  182. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/RECORD +185 -177
  183. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/WHEEL +0 -0
  184. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/entry_points.txt +0 -0
  185. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/licenses/LICENSE +0 -0
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, TypeAlias
9
9
 
10
10
  from dataface.core.compile.config import get_chart_rendering
11
11
  from dataface.core.render.chart.artifacts import ChartRenderData
12
- from dataface.core.render.chart.vl_field_maps import SCALE_FIELD_MAP, map_fields
12
+ from dataface.core.render.chart.vl_field_maps import emit_resolved_scale_vl
13
13
  from dataface.core.text.format_d3 import is_time_format
14
14
 
15
15
  if TYPE_CHECKING:
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
17
17
 
18
18
  # Date-only ISO forms (YYYY-MM, YYYY-MM-DD) — the only string shapes JS
19
19
  # Date.parse treats as UTC midnight, making them safe domain values for the
20
- # label.values epoch-ms membership filter on ordinal axes. Datetime strings
20
+ # labels.values epoch-ms membership filter on ordinal axes. Datetime strings
21
21
  # without an offset parse as LOCAL time in JS and are deliberately excluded.
22
22
  _ISO_UTC_SAFE_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$")
23
23
 
@@ -126,7 +126,10 @@ def resolve_authored_x_type(axis: ResolvedAxisStyle) -> str | None:
126
126
  """
127
127
  authored_type = axis.type
128
128
  if authored_type in (None, "auto") and axis.scale is not None:
129
- if axis.scale.type == "temporal":
129
+ _ax_scale_type = (
130
+ axis.scale.continuous.type if axis.scale.continuous is not None else None
131
+ )
132
+ if _ax_scale_type == "temporal":
130
133
  authored_type = "temporal"
131
134
  return authored_type
132
135
 
@@ -196,7 +199,7 @@ def resolve_cartesian_x_type(
196
199
 
197
200
  def temporal_edge_labels_flushed(vl_type: str, axis: ResolvedAxisStyle) -> bool:
198
201
  """Whether Vega flushes this temporal axis's outer labels into the plot."""
199
- return vl_type == "temporal" and axis.label.flush is not False
202
+ return vl_type == "temporal" and axis.labels.flush is not False
200
203
 
201
204
 
202
205
  def build_cartesian_x_encoding(
@@ -250,7 +253,7 @@ def build_cartesian_x_encoding(
250
253
  )
251
254
  from dataface.core.render.chart.vl_field_maps import _n
252
255
 
253
- label_values: list[Any] | None = _n(axis, "label", "values")
256
+ label_values: list[Any] | None = _n(axis, "labels", "values")
254
257
 
255
258
  # label_tu is the resolved label cadence for a bucketed grain — computed once
256
259
  # so both the density gate below and the axis-values/labelExpr enrichment
@@ -258,7 +261,7 @@ def build_cartesian_x_encoding(
258
261
  label_tu: str | None = None
259
262
  authored_label_tu: str | None = None
260
263
  if time_unit and time_unit in BUCKETED_CALENDAR_UNITS:
261
- authored_label_tu = getattr(getattr(axis, "label", None), "time_unit", None)
264
+ authored_label_tu = getattr(getattr(axis, "labels", None), "time_unit", None)
262
265
  label_tu = (
263
266
  format_time_unit
264
267
  if format_time_unit
@@ -289,7 +292,7 @@ def build_cartesian_x_encoding(
289
292
  label_tu in BUCKETED_CALENDAR_UNITS
290
293
  and label_tu != time_unit
291
294
  and axis.ticks.count is None
292
- and axis.ticks.interval is None
295
+ and axis.ticks.time_unit is None
293
296
  and "values" not in ax_vl
294
297
  )
295
298
  label_tick_values: list[Any] = []
@@ -322,7 +325,7 @@ def build_cartesian_x_encoding(
322
325
  )
323
326
  temporal_anchor_value = str((anchor_openers or anchor_values[:1])[0])
324
327
 
325
- # label.values needs the epoch-ms membership filter to match every
328
+ # labels.values needs the epoch-ms membership filter to match every
326
329
  # listed date exactly. On a continuous temporal scale datum.value is
327
330
  # already epoch ms — always exact. On any other emit path datum.value is
328
331
  # the raw domain value, which Vega parses with JS Date semantics: only
@@ -350,7 +353,11 @@ def build_cartesian_x_encoding(
350
353
  raise ChartDataError.from_code(
351
354
  ERR_LABEL_VALUES_NOT_TEMPORAL,
352
355
  field=x_field,
353
- example=row[x_field],
356
+ cause=f"it contains {row[x_field]!r}, which isn't a UTC-safe ISO date",
357
+ remedy=(
358
+ "Use date-only ISO dates (YYYY-MM-DD or YYYY-MM) or date "
359
+ "objects in the query, or remove labels.values."
360
+ ),
354
361
  )
355
362
 
356
363
  result = dict(ax_vl)
@@ -430,15 +437,15 @@ def build_cartesian_x_encoding(
430
437
  )
431
438
  if smart_expr_t is not None:
432
439
  result["labelExpr"] = smart_expr_t
433
- # Step-anchored cadence (axis.ticks.interval/step) requires a genuinely
440
+ # Step-anchored cadence (axis.ticks.time_unit/step) requires a genuinely
434
441
  # continuous temporal x-axis — compile-time gates (_bake_cartesian_axes)
435
- # already reject it on axis_y and reject step-without-interval, but
442
+ # already reject it on axis_y and reject step-without-time_unit, but
436
443
  # whether THIS axis resolves to temporal vs ordinal is a data-dependent
437
444
  # decision only known here. Unlike count (which silently no-ops on
438
- # ordinal — see the density-gate test suite), interval/step raise: the
445
+ # ordinal — see the density-gate test suite), time_unit/step raise: the
439
446
  # whole point of the surface is an explicit cadence, so a silent no-op
440
447
  # would hide a misconfiguration rather than surface it.
441
- if axis.ticks.interval is not None and vl_type != "temporal":
448
+ if axis.ticks.time_unit is not None and vl_type != "temporal":
442
449
  from dataface.core.diagnostics.chart_data import ChartDataError
443
450
  from dataface.core.diagnostics.codes_render import (
444
451
  ERR_TICKS_INTERVAL_NOT_TEMPORAL,
@@ -448,17 +455,29 @@ def build_cartesian_x_encoding(
448
455
  ERR_TICKS_INTERVAL_NOT_TEMPORAL, field=x_field, vl_type=vl_type
449
456
  )
450
457
 
451
- # Authored ticks.count/interval on a temporal scale maps straight to VL's
458
+ # Authored ticks.count/time_unit on a temporal scale maps straight to VL's
452
459
  # axis.tickCount (a plain number, or {interval, step} for step-anchored
453
- # cadence — see _resolve_cartesian_ticks for the quantitative-axis
460
+ # cadence — VL's own wire format uses "interval" regardless of Dataface's
461
+ # field name — see _resolve_cartesian_ticks for the quantitative-axis
454
462
  # explicit-values path, which is unrelated). Ordinal axes are out of
455
463
  # scope: tickCount has no meaning against a discrete-domain ordinal
456
464
  # scale, so this never fires for vl_type == "ordinal". axis.ticks is a
457
465
  # required ResolvedAxisStyle field — read it directly and crash loudly on
458
466
  # a malformed axis object rather than silently skipping.
459
467
  if vl_type == "temporal" and "tickCount" not in result and "values" not in result:
460
- if axis.ticks.interval is not None:
461
- tick_count: dict[str, Any] = {"interval": axis.ticks.interval}
468
+ _ticks_time_unit = axis.ticks.time_unit
469
+ if _ticks_time_unit is not None:
470
+ if _ticks_time_unit not in _TEMPORAL_TICK_INTERVAL:
471
+ from dataface.core.diagnostics.chart_data import ChartDataError
472
+
473
+ raise ChartDataError(
474
+ f"ticks.time_unit: {_ticks_time_unit!r} has no step-anchored "
475
+ "cadence — only a calendar-bucketing grain "
476
+ f"({', '.join(_TEMPORAL_TICK_INTERVAL)}) names a VL tick "
477
+ "interval."
478
+ )
479
+ interval, _ = _TEMPORAL_TICK_INTERVAL[_ticks_time_unit]
480
+ tick_count: dict[str, Any] = {"interval": interval}
462
481
  if axis.ticks.step is not None:
463
482
  tick_count["step"] = axis.ticks.step
464
483
  result["tickCount"] = tick_count
@@ -478,7 +497,7 @@ def build_cartesian_x_encoding(
478
497
  )
479
498
 
480
499
  result = inject_axis_label_case(result, axis)
481
- # label.values filter runs last of all — it wins over any smart-cadence
500
+ # labels.values filter runs last of all — it wins over any smart-cadence
482
501
  # or case-transformed text already resolved above.
483
502
  result = inject_axis_label_values_filter(result, axis)
484
503
  return vl_type, result, time_unit
@@ -490,21 +509,22 @@ def y_zero_scale(
490
509
  ) -> dict[str, Any]:
491
510
  """Build a VL y-scale dict from the resolved y-axis style.
492
511
 
493
- Merges every ``SCALE_FIELD_MAP`` field (``type``, ``base``, ``exponent``,
494
- etc.) the same way scatter's ``_build_axis_scale`` does — one canonical
495
- mapper, so a measure-axis ``scale.type: log`` (previously honored only on
496
- scatter) reaches line/area/bar too. ``zero`` is excluded from that merge
497
- and computed separately:
512
+ Merges every scale field (``type``, ``base``, ``exponent``, etc.) via
513
+ ``emit_resolved_scale_vl`` — one canonical emitter, so a measure-axis
514
+ ``scale.type: log`` (previously honored only on scatter) reaches
515
+ line/area/bar too. ``zero`` is excluded from that merge and computed
516
+ separately:
498
517
 
499
518
  axis.scale.zero is True → {"domainMin": tick_values[0] or 0.0, "zero": True}
500
519
  anything else → {"zero": False}
501
520
  """
502
521
  scale = getattr(axis, "scale", None) if axis is not None else None
503
- zero = getattr(scale, "zero", None) if scale is not None else None
522
+ _sc_cont = scale.continuous if scale is not None else None
523
+ zero = _sc_cont.zero if _sc_cont is not None else None
504
524
 
505
525
  out: dict[str, Any] = {}
506
526
  if scale is not None:
507
- out.update(map_fields(scale, SCALE_FIELD_MAP))
527
+ out.update(emit_resolved_scale_vl(scale))
508
528
  out.pop("zero", None) # zero is computed below, not passed through raw
509
529
 
510
530
  if zero is True:
@@ -6,6 +6,7 @@ Keeps data-shape policy separate from mechanical spec assembly.
6
6
  from __future__ import annotations
7
7
 
8
8
  from collections import Counter
9
+ from decimal import Decimal
9
10
  from typing import Any
10
11
 
11
12
  from dataface.core.compile.models.chart.resolved import (
@@ -18,9 +19,15 @@ from dataface.core.diagnostics.chart_data import ChartDataError
18
19
  from dataface.core.diagnostics.codes_render import (
19
20
  ERR_BAR_DUPLICATE_ROWS,
20
21
  ERR_COLOR_NULL_SERIES,
22
+ ERR_HISTOGRAM_PREAGGREGATED,
21
23
  )
22
24
  from dataface.core.render.chart.type_inference import infer_vega_type_from_data
23
25
 
26
+ # A run this short forming a gapless integer sequence by chance is not
27
+ # meaningfully unusual for genuine raw data; below this length the "dense
28
+ # integer run" signal in _forms_gapless_integer_run is too weak to act on.
29
+ _MIN_RUN_FOR_GROUP_BY_SHAPE = 5
30
+
24
31
 
25
32
  def validate_color_series(
26
33
  chart: ResolvedBarChart | ResolvedLineChart | ResolvedAreaChart,
@@ -172,3 +179,87 @@ def validate_preaggregated_data(
172
179
  field_list=field_list,
173
180
  duplicate_preview=duplicate_preview,
174
181
  )
182
+
183
+
184
+ def _forms_gapless_integer_run(values: list[Any]) -> bool:
185
+ """True when every value is integer-valued, none repeats, and together
186
+ they cover every integer between the min and max with no gaps.
187
+
188
+ This is exactly the shape ``GROUP BY <integer column>`` produces: one row
189
+ per distinct observed value. It is deliberately *not* "zero duplicate
190
+ values" alone — a continuous measure (the primary histogram use case,
191
+ e.g. price, latency) is essentially always duplicate-free too, so that
192
+ signal can't tell raw from aggregated. A dense, gapless run of whole
193
+ numbers is different: real raw samples have gaps and skew; hitting every
194
+ integer in a range exactly once is the kind of thing an integer bucket
195
+ key produces by construction; a continuous measure practically never
196
+ produces by chance.
197
+ """
198
+ if len(values) < _MIN_RUN_FOR_GROUP_BY_SHAPE:
199
+ return False
200
+
201
+ whole_numbers: set[int] = set()
202
+ for value in values:
203
+ if isinstance(value, bool):
204
+ return False
205
+ if isinstance(value, int):
206
+ whole = value
207
+ elif (isinstance(value, float) and value.is_integer()) or (
208
+ isinstance(value, Decimal) and value == value.to_integral_value()
209
+ ):
210
+ whole = int(value)
211
+ else:
212
+ return False
213
+ if whole in whole_numbers:
214
+ return False
215
+ whole_numbers.add(whole)
216
+
217
+ return max(whole_numbers) - min(whole_numbers) + 1 == len(whole_numbers)
218
+
219
+
220
+ def validate_raw_rows_for_histogram(
221
+ chart: ResolvedBarChart,
222
+ data: list[dict[str, Any]],
223
+ ) -> None:
224
+ """Reject pre-aggregated data on a histogram — the mirror image of
225
+ ``validate_preaggregated_data``.
226
+
227
+ Histograms hand Vega-Lite raw, ungrouped rows and let VL do its own
228
+ binning + counting; a histogram's ``y`` is always ``aggregate: count``,
229
+ never a data column. A pre-aggregated table — the output of
230
+ ``SELECT x, count(*) FROM t GROUP BY x`` — carries that count (and
231
+ possibly other aggregates) in leftover numeric columns the emitter never
232
+ reads, and ``x`` forms a gapless run of whole numbers (what ``GROUP BY``
233
+ over an integer bucket produces — see ``_forms_gapless_integer_run``).
234
+ Both conditions together are the tell; either alone is common in genuine
235
+ raw data (a raw fact table often carries a spare numeric column — an id,
236
+ a second measure — and a continuous measure is duplicate-free as a
237
+ matter of course). Feeding pre-aggregated data to VL would bin and
238
+ recount the already-counted rows, silently discarding the real count.
239
+ """
240
+ if not data or chart.x is None:
241
+ return
242
+ x_field = chart.x
243
+
244
+ color_field = effective_color_field(chart)
245
+ extra_columns = [
246
+ column for column in data[0] if column not in {x_field, color_field}
247
+ ]
248
+ numeric_extra_columns = [
249
+ column
250
+ for column in extra_columns
251
+ if infer_vega_type_from_data(data, column) == "quantitative"
252
+ ]
253
+ if not numeric_extra_columns:
254
+ return
255
+
256
+ x_values = [row.get(x_field) for row in data]
257
+ if not _forms_gapless_integer_run(x_values):
258
+ return
259
+
260
+ raise ChartDataError.from_code(
261
+ ERR_HISTOGRAM_PREAGGREGATED,
262
+ chart_id=chart.id or "unknown",
263
+ field=x_field,
264
+ count_fields=", ".join(repr(column) for column in numeric_extra_columns),
265
+ )
@@ -40,6 +40,7 @@ def _trace_vl_spec(chart_id: str, renderer: str, spec: dict[str, Any]) -> None:
40
40
  from dataface.core.compile.models.chart.resolved import ResolvedChart
41
41
  from dataface.core.compile.models.chart.resolved._base import (
42
42
  _CartesianResolvedChartFields,
43
+ _SharedResolvedChartFields,
43
44
  )
44
45
  from dataface.core.compile.models.chart.resolved.bar import ResolvedBarChart
45
46
  from dataface.core.compile.models.chart.resolved.pie import ResolvedPieChart
@@ -98,6 +99,10 @@ def _render_vl_artifact(
98
99
  )
99
100
  if svg is not None:
100
101
  return RenderArtifact(kind="svg", payload=svg)
102
+ # render_svg_family only returns None for the VL families, all of which
103
+ # are _SharedResolvedChartFields subclasses (KPI/callout/table/spark_bar
104
+ # return svg above) — title_style is guaranteed present below.
105
+ assert isinstance(resolved, _SharedResolvedChartFields)
101
106
 
102
107
  effective_height = height
103
108
  if (
@@ -144,13 +149,24 @@ def _render_vl_artifact(
144
149
  if padding is not None:
145
150
  vl["padding"] = padding
146
151
  _apply_facet_layout(vl, render_data, width, effective_height)
147
- apply_title_overflow_to_spec(vl, resolved_style.charts.title)
152
+ apply_title_overflow_to_spec(vl, resolved.title_style)
148
153
  _trace_vl_spec(resolved.id, "v2", vl)
149
154
  return RenderArtifact(kind="vega_spec", payload=vl)
150
155
  if "hconcat" in vl:
151
156
  size_target = vl["hconcat"][0]
152
157
  if "title" in vl:
153
158
  size_target["title"] = vl.pop("title")
159
+ # hconcat's title wrap is deferred to render_vega_spec (see the
160
+ # comment below) — stamp the chart's own title_style so the deferred
161
+ # wrap reads the same chart-local source as the non-hconcat path
162
+ # above, instead of re-deriving it from the board-level style bag.
163
+ # A plain dict, not the live TitleStyle object: this sentinel can
164
+ # leak into a raw spec dict handed directly to vl-convert (e.g.
165
+ # generate_vega_lite_spec()'s return value, or a RenderArtifact
166
+ # payload used outside render_chart_artifact) — an unrecognized
167
+ # dict-valued key is harmless there, but a Pydantic object is not
168
+ # JSON-serializable.
169
+ vl["$df_title_style"] = resolved.title_style.model_dump()
154
170
  else:
155
171
  size_target = vl
156
172
  if padding is not None:
@@ -179,7 +195,7 @@ def _render_vl_artifact(
179
195
  # still the full column width — wrapping against it now overstates
180
196
  # the room the title actually has. render_vega_spec wraps it after
181
197
  # _correct_concat_overshoot shrinks pane[0] to its real width.
182
- apply_title_overflow_to_spec(size_target, resolved_style.charts.title)
198
+ apply_title_overflow_to_spec(size_target, resolved.title_style)
183
199
  _trace_vl_spec(resolved.id, "v2", vl)
184
200
  return RenderArtifact(kind="vega_spec", payload=vl)
185
201
 
@@ -424,12 +440,9 @@ def generate_vega_lite_spec(
424
440
  SVG-family charts (kpi, table, spark_bar, callout) have no Vega-Lite
425
441
  spec; callers that ask for one get ERR_VEGA_LITE_UNSUPPORTED_TYPE.
426
442
  """
427
- import dataclasses
428
-
429
443
  from dataface.core.compile.config import get_theme_style
430
444
  from dataface.core.compile.models.style.resolved import resolve_style
431
445
  from dataface.core.compile.resolve import preferred_chart_width, resolve
432
- from dataface.core.compile.style_cascade import build_resolved_style
433
446
 
434
447
  style = board_style if board_style is not None else resolve_style(get_theme_style())
435
448
  # resolve() dispatches on chart.type first and raises for unsupported/synthetic
@@ -442,14 +455,13 @@ def generate_vega_lite_spec(
442
455
  if isinstance(resolved, ResolvedPieChart)
443
456
  else width if width is not None else preferred_chart_width(chart, style)
444
457
  )
445
- # Merge chart-local style (e.g. bar.title.font.size) into board_style.charts so
446
- # finalize_vl picks up authored title overrides when computing the VL spec config.
447
- chart_charts_style = build_resolved_style(style, chart)
448
- render_style = dataclasses.replace(style, charts=chart_charts_style)
458
+ # The resolved chart already carries its final chart-local presentation
459
+ # values (background, title_style) baked in at resolve() — no second
460
+ # style decision here. style is the unchanged face-level style.
449
461
  artifact = _render_vl_artifact(
450
462
  resolved,
451
463
  data,
452
- render_style,
464
+ style,
453
465
  width=resolved_width,
454
466
  height=height,
455
467
  is_placeholder=False,
@@ -462,7 +474,14 @@ def generate_vega_lite_spec(
462
474
  )
463
475
  assert isinstance(artifact.payload, dict)
464
476
  vl = _apply_data_table_strip(artifact.payload, resolved, style.charts, data)
465
- return vl if vl is not None else artifact.payload
477
+ result = vl if vl is not None else artifact.payload
478
+ # $df_title_style is an internal hand-off to render_vega_spec's deferred
479
+ # hconcat title wrap (converters/chart.py) — this entry point returns the
480
+ # spec directly without going through that conversion, and the sentinel
481
+ # holds a non-JSON-serializable TitleStyle object, so it must not leak
482
+ # into the public return value.
483
+ result.pop("$df_title_style", None)
484
+ return result
466
485
 
467
486
 
468
487
  def render_chart(