Draco Debugging#
import toml
from draco import Draco
from draco.debug import DracoDebug, DracoDebugChartConfig, DracoDebugPlotter
Why doesn’t Draco produce what I want?#
There are several reasons:
First, the generator in generate.lp
may not be capable of enumerate every possibility. Then, the answer you have in mind might also have been left out by hard constraints. An extreme situation you might observe is that Draco returns an empty result. If you have edge cases you want to test on, you can add constraints temporarily to check if Draco can still generate answers. For how to do this, refer to How to modify the knowledge-base with constraints. Then you can extend the generator or revise the hard constraints accordingly after making sure your testing cases are well-defined.
If you have pinpointed the issue to the hard constraints collection, you might want to take a step to relax the hard constraints for further investigation before you make any changes. For how to do this, refer to Debugging empty results.
Second, the answer might have a low ranking so that Draco promotes other answers instead. This can be caused by lack of proper soft constraints, incorrectly-defined soft constraints, or unsuitable constraint weights. To make sure the soft constraints are well-defined, add more unit tests and refer to Validating the soft constraints with full specifications. For soft constraint introspection, check out How to modify the knowledge-base with constraints.
In general, here is a guideline for what to do:
If you see that there are too many recommendations, you can:
add more hard constraints
modify your generator and hard constraints to reduce symmetry in the search space (e.g. similar recommendations with switched entity ids)
If you see too few recommendations, you can:
check if some of your constraints are too tight, and move them to soft constraints
If you see no recommendations, you might have made mistakes in the hard constraints. You can allow violations to check what are the common ones by removing the
violation
constraint, which forbids any violations, from the programs.
Validating the soft constraints with full specifications#
Although we ensure 100% testing coverage including all soft constraints, we might want to assess and validate that the soft constraint collection carries out the intended goal in real-world visualization designs.
Here we demonstrate how we validate the soft constraints violated in specific examples.
Loading Specifications#
Specifications to debug can be declared as a dictionary or can be loaded from a static file. The debugger will generate a dataframe from your input automatically, ready to be analyzed!
example_specs = toml.load(open("./data/example_charts.toml"))
default_draco = Draco()
debugger = DracoDebug(specs=example_specs, draco=default_draco)
debugger.chart_preferences.head()
chart_name | pref_name | pref_description | count | weight | |
---|---|---|---|---|---|
0 | tick_plot | linear_x | Linear scale with x channel. | 1 | 0 |
1 | tick_plot | c_d_overlap_tick | Continuous by discrete for tick mark. | 1 | 0 |
2 | tick_plot | linear_scale | linear scale. | 1 | 0 |
3 | tick_plot | continuous_pos_not_zero | Prefer zero continuous positional. | 1 | 1 |
4 | tick_plot | continuous_not_zero | Prefer to include zero for continuous (binned ... | 1 | 1 |
Visualizing Debug Data#
The chart_preferences
dataframe generated by DracoDebug
can be visualized using DracoDebugPlotter
. Custom plot configurations can be passed to DracoDebugPlotter.create_chart
to customize the produced visualization.
plotter = DracoDebugPlotter(debugger.chart_preferences)
# Creates a chart using the default config (alphabetical sort)
plotter.create_chart()
Interactive Selection of Debugging Variants#
Interactions will work only in a Python-enabled environment! Please clone the repository or start it in Binder.
import ipywidgets as widgets
config_title_dropdown = widgets.Dropdown(
options=[cfg.value.title for cfg in DracoDebugChartConfig],
value=DracoDebugChartConfig.SORT_ALPHABETICALLY.value.title,
description="Sorting:",
disabled=False,
)
violated_prefs_only_checkbox = widgets.Checkbox(
value=False,
description="Show violated preferences only",
disabled=False,
)
def create_size_slider(value: float, description: str):
return widgets.FloatSlider(
value=value,
min=100,
max=1800,
step=10,
description=description,
disabled=False,
continuous_update=False,
orientation="horizontal",
readout=True,
readout_format=".1f",
)
size_slider_width = create_size_slider(value=1000, description="Width:")
size_slider_height = create_size_slider(value=400, description="Height:")
def on_config_change(
config_title: str, violated_prefs_only: bool, width: float, height: float
):
# Creating new instances to avoid collisions with other variables in this notebook
dbg = DracoDebug(specs=example_specs, draco=default_draco)
plt = DracoDebugPlotter(dbg.chart_preferences)
chart = plt.create_chart(
cfg=DracoDebugChartConfig.by_title(config_title),
violated_prefs_only=violated_prefs_only,
plot_size=(width, height),
)
display(chart)
out = widgets.interactive_output(
on_config_change,
{
"config_title": config_title_dropdown,
"violated_prefs_only": violated_prefs_only_checkbox,
"width": size_slider_width,
"height": size_slider_height,
},
)
controls = widgets.HBox(
[
config_title_dropdown,
violated_prefs_only_checkbox,
widgets.VBox([size_slider_width, size_slider_height]),
]
)
widgets.VBox([controls, out])
With the heatmap above, we can verify if each soft constraint expresses the intended rule under the context of visualization designs. In the collection above, there are groups of visualizations that are more relevant to each other in terms of their specification. For example, knowing that the most significant difference between a bubble chart
and a colored scatterplot
is that a bubble chart
has a quantitative size
channel while a colored scatterplot
has a categorical color
channel, one can verify that the relevant constraints are violated accordingly (i.e., categorical_scale
and categorical_color
for the colored scatterplot
, and the size_not_zero
and linear_size
for the bubble chart
).
In addition, the heatmap indicates the coverage of violations in the chosen example. We can then incrementally add new examples that focus on constraints with low/zero coverage.
On the other hand, when we identify that the existing constraints saturated for substantially different example, we might be able to uncover new constraints to extend the knowledge space.
How to modify the knowledge base with constraints#
You can design your own description language and use it with Draco or extend the existing language we use here. If you don’t know where to start with the constraints, you can first use our Draco APIs to generate some recommendations. Then, you should be able to find some recommendations that should have been left out, and you can write constraints to reflect them.
If you write your own description language, you need to set up the search space in a similar way to generate.lp
before trying to generate recommendations.
For example, the following snippet shows how to generate 5 recommendation. You can set a different number to look into more results.
from pprint import pprint
from draco import Draco, answer_set_to_dict, dict_to_facts, schema_from_dataframe
d = Draco()
from IPython.display import display
from vega_datasets import data
from draco.renderer import AltairRenderer
# Setting up renderer and demo data
renderer = AltairRenderer()
weather_data = data.seattle_weather()
# Generating facts about data
weather_schema = schema_from_dataframe(weather_data)
weather_facts = dict_to_facts(weather_schema)
input_spec = weather_facts + [
"attribute(task,root,summary).",
"entity(view,root,(v,0)).",
"entity(mark,(v,0),(m,0)).",
"entity(encoding,(m,0),(e,0)).",
"attribute((encoding,field),(e,0),temp_max).",
"entity(encoding,(m,0),(e,1)).",
"attribute((encoding,field),(e,1),precipitation).",
"#show entity/3.",
"#show attribute/3.",
]
print("INPUT:")
print(input_spec)
print("OUTPUT:")
specs = {}
for i, model in enumerate(d.complete_spec(input_spec, 5)):
chart_num = i + 1
spec = answer_set_to_dict(model.answer_set)
chart_name = f"Rec {chart_num}"
specs[chart_name] = dict_to_facts(spec)
print(f"CHART {chart_num}")
print(f"COST: {model.cost}")
pprint(spec)
display(renderer.render(spec=spec, data=weather_data))
print("VIOLATED PREFERENCES:")
debugger = DracoDebug(specs=specs, draco=default_draco)
chart_preferences = debugger.chart_preferences
plotter = DracoDebugPlotter(chart_preferences)
# sort by sum of count
plotter.create_chart(cfg=DracoDebugChartConfig.SORT_BY_COUNT_SUM)
INPUT:
['attribute(number_rows,root,1461).', 'entity(field,root,0).', 'attribute((field,name),0,date).', 'attribute((field,type),0,datetime).', 'attribute((field,unique),0,1461).', 'attribute((field,entropy),0,7287).', 'entity(field,root,1).', 'attribute((field,name),1,precipitation).', 'attribute((field,type),1,number).', 'attribute((field,unique),1,111).', 'attribute((field,entropy),1,2422).', 'attribute((field,min),1,0).', 'attribute((field,max),1,55).', 'attribute((field,std),1,6).', 'entity(field,root,2).', 'attribute((field,name),2,temp_max).', 'attribute((field,type),2,number).', 'attribute((field,unique),2,67).', 'attribute((field,entropy),2,3934).', 'attribute((field,min),2,-1).', 'attribute((field,max),2,35).', 'attribute((field,std),2,7).', 'entity(field,root,3).', 'attribute((field,name),3,temp_min).', 'attribute((field,type),3,number).', 'attribute((field,unique),3,55).', 'attribute((field,entropy),3,3596).', 'attribute((field,min),3,-7).', 'attribute((field,max),3,18).', 'attribute((field,std),3,5).', 'entity(field,root,4).', 'attribute((field,name),4,wind).', 'attribute((field,type),4,number).', 'attribute((field,unique),4,79).', 'attribute((field,entropy),4,3950).', 'attribute((field,min),4,0).', 'attribute((field,max),4,9).', 'attribute((field,std),4,1).', 'entity(field,root,5).', 'attribute((field,name),5,weather).', 'attribute((field,type),5,string).', 'attribute((field,unique),5,5).', 'attribute((field,entropy),5,1201).', 'attribute((field,freq),5,714).', 'attribute(task,root,summary).', 'entity(view,root,(v,0)).', 'entity(mark,(v,0),(m,0)).', 'entity(encoding,(m,0),(e,0)).', 'attribute((encoding,field),(e,0),temp_max).', 'entity(encoding,(m,0),(e,1)).', 'attribute((encoding,field),(e,1),precipitation).', '#show entity/3.', '#show attribute/3.']
OUTPUT:
CHART 1
COST: [14]
{'field': [{'entropy': 7287,
'name': 'date',
'type': 'datetime',
'unique': 1461},
{'entropy': 2422,
'max': 55,
'min': 0,
'name': 'precipitation',
'std': 6,
'type': 'number',
'unique': 111},
{'entropy': 3934,
'max': 35,
'min': -1,
'name': 'temp_max',
'std': 7,
'type': 'number',
'unique': 67},
{'entropy': 3596,
'max': 18,
'min': -7,
'name': 'temp_min',
'std': 5,
'type': 'number',
'unique': 55},
{'entropy': 3950,
'max': 9,
'min': 0,
'name': 'wind',
'std': 1,
'type': 'number',
'unique': 79},
{'entropy': 1201,
'freq': 714,
'name': 'weather',
'type': 'string',
'unique': 5}],
'number_rows': 1461,
'task': 'summary',
'view': [{'coordinates': 'cartesian',
'mark': [{'encoding': [{'channel': 'y', 'field': 'temp_max'},
{'channel': 'x', 'field': 'precipitation'}],
'type': 'point'}],
'scale': [{'channel': 'y', 'type': 'linear'},
{'channel': 'x', 'type': 'linear', 'zero': 'true'}]}]}
CHART 2
COST: [14]
{'field': [{'entropy': 7287,
'name': 'date',
'type': 'datetime',
'unique': 1461},
{'entropy': 2422,
'max': 55,
'min': 0,
'name': 'precipitation',
'std': 6,
'type': 'number',
'unique': 111},
{'entropy': 3934,
'max': 35,
'min': -1,
'name': 'temp_max',
'std': 7,
'type': 'number',
'unique': 67},
{'entropy': 3596,
'max': 18,
'min': -7,
'name': 'temp_min',
'std': 5,
'type': 'number',
'unique': 55},
{'entropy': 3950,
'max': 9,
'min': 0,
'name': 'wind',
'std': 1,
'type': 'number',
'unique': 79},
{'entropy': 1201,
'freq': 714,
'name': 'weather',
'type': 'string',
'unique': 5}],
'number_rows': 1461,
'task': 'summary',
'view': [{'coordinates': 'cartesian',
'mark': [{'encoding': [{'binning': 10,
'channel': 'y',
'field': 'temp_max'},
{'channel': 'x', 'field': 'precipitation'}],
'type': 'tick'}],
'scale': [{'channel': 'y', 'type': 'linear'},
{'channel': 'x', 'type': 'linear', 'zero': 'true'}]}]}
CHART 3
COST: [14]
{'field': [{'entropy': 7287,
'name': 'date',
'type': 'datetime',
'unique': 1461},
{'entropy': 2422,
'max': 55,
'min': 0,
'name': 'precipitation',
'std': 6,
'type': 'number',
'unique': 111},
{'entropy': 3934,
'max': 35,
'min': -1,
'name': 'temp_max',
'std': 7,
'type': 'number',
'unique': 67},
{'entropy': 3596,
'max': 18,
'min': -7,
'name': 'temp_min',
'std': 5,
'type': 'number',
'unique': 55},
{'entropy': 3950,
'max': 9,
'min': 0,
'name': 'wind',
'std': 1,
'type': 'number',
'unique': 79},
{'entropy': 1201,
'freq': 714,
'name': 'weather',
'type': 'string',
'unique': 5}],
'number_rows': 1461,
'task': 'summary',
'view': [{'coordinates': 'cartesian',
'mark': [{'encoding': [{'channel': 'x', 'field': 'temp_max'},
{'channel': 'y', 'field': 'precipitation'}],
'type': 'point'}],
'scale': [{'channel': 'x', 'type': 'linear'},
{'channel': 'y', 'type': 'linear', 'zero': 'true'}]}]}
CHART 4
COST: [14]
{'field': [{'entropy': 7287,
'name': 'date',
'type': 'datetime',
'unique': 1461},
{'entropy': 2422,
'max': 55,
'min': 0,
'name': 'precipitation',
'std': 6,
'type': 'number',
'unique': 111},
{'entropy': 3934,
'max': 35,
'min': -1,
'name': 'temp_max',
'std': 7,
'type': 'number',
'unique': 67},
{'entropy': 3596,
'max': 18,
'min': -7,
'name': 'temp_min',
'std': 5,
'type': 'number',
'unique': 55},
{'entropy': 3950,
'max': 9,
'min': 0,
'name': 'wind',
'std': 1,
'type': 'number',
'unique': 79},
{'entropy': 1201,
'freq': 714,
'name': 'weather',
'type': 'string',
'unique': 5}],
'number_rows': 1461,
'task': 'summary',
'view': [{'coordinates': 'cartesian',
'mark': [{'encoding': [{'binning': 10,
'channel': 'x',
'field': 'temp_max'},
{'channel': 'y', 'field': 'precipitation'}],
'type': 'tick'}],
'scale': [{'channel': 'x', 'type': 'linear'},
{'channel': 'y', 'type': 'linear', 'zero': 'true'}]}]}
CHART 5
COST: [16]
{'field': [{'entropy': 7287,
'name': 'date',
'type': 'datetime',
'unique': 1461},
{'entropy': 2422,
'max': 55,
'min': 0,
'name': 'precipitation',
'std': 6,
'type': 'number',
'unique': 111},
{'entropy': 3934,
'max': 35,
'min': -1,
'name': 'temp_max',
'std': 7,
'type': 'number',
'unique': 67},
{'entropy': 3596,
'max': 18,
'min': -7,
'name': 'temp_min',
'std': 5,
'type': 'number',
'unique': 55},
{'entropy': 3950,
'max': 9,
'min': 0,
'name': 'wind',
'std': 1,
'type': 'number',
'unique': 79},
{'entropy': 1201,
'freq': 714,
'name': 'weather',
'type': 'string',
'unique': 5}],
'number_rows': 1461,
'task': 'summary',
'view': [{'coordinates': 'cartesian',
'mark': [{'encoding': [{'channel': 'x', 'field': 'temp_max'},
{'binning': 10,
'channel': 'y',
'field': 'precipitation'}],
'type': 'tick'}],
'scale': [{'channel': 'x', 'type': 'linear'},
{'channel': 'y', 'type': 'linear', 'zero': 'true'}]}]}
VIOLATED PREFERENCES:
We can add hard constraints temporarily to further filter and inspect the answer set.
Here we exclude the multi-layer or multi-view designs, and we ensure the recommendations having at least 3 encodings.
# exclude multi-layer or multi-view designs.
# filter out designs with less than 3 encodings
input_spec = [
"attribute(task,root,summary).",
"attribute(number_rows,root,100).",
"entity(field,root,temp_max).",
"attribute((field,name),temp_max,temp_max).",
"attribute((field,type),temp_max,number).",
"attribute((field,unique),temp_max,100).",
"attribute((field,min),temp_max,-1).",
"attribute((field,max),temp_max,35).",
"entity(field,root,f1).",
"attribute((field,name),f1,precipitation).",
"attribute((field,type),f1,number).",
"entity(view,root,(v,0)).",
"entity(mark,(v,0),(m,0)).",
"entity(encoding,(m,0),(e,0)).",
"attribute((encoding,field),(e,0),temp_max).",
"entity(encoding,(m,0),(e,1)).",
"attribute((encoding,field),(e,1),precipitation).",
"#show entity/3.",
"#show attribute/3.",
]
new_input = input_spec + [
":- {entity(encoding,_,_)} <= 2.",
":- {entity(mark,_,_)} >= 2.",
]
We will demonstrate how we found a bug in one of our soft constraints and fixed it.
At the beginning, the soft constraint binned_orientation_not_x
was incorrectly defined as:
preference(binned_orientation_not_x,E) :-
attribute((field,type),F,(number;datetime)),
helper((encoding,field),E,F),
attribute((encoding,binning),E,_),
not attribute((encoding,channel),E,x).
The reason why this is incorrect is explained below, after the charts in this section.
Now, to simulate our debugging process, let’s replace the now correct definition with the incorrect version:
from draco.asp_utils import blocks_to_program
from draco.programs import soft
# remove the correct definition, and then replace it with the incorrect one.
s = "".join(
blocks_to_program(
soft.blocks, set(soft.blocks.keys()) - {"binned_orientation_not_x"}
)
)
s += """preference(binned_orientation_not_x,E) :-
attribute((field,type),F,(number;datetime)),
helper((encoding,field),E,F),
attribute((encoding,binning),E,_),
not attribute((encoding,channel),E,x)."""
new_draco = Draco(soft=s)
specs = {}
for i, model in enumerate(new_draco.complete_spec(new_input, 3)):
chart_num = i + 1
spec = answer_set_to_dict(model.answer_set)
chart_name = f"Rec {chart_num}"
specs[chart_name] = dict_to_facts(spec)
print(f"CHART {chart_num}")
print(f"COST: {model.cost}")
pprint(spec)
display(renderer.render(spec=spec, data=weather_data))
print("VIOLATED PREFERENCES:")
debugger = DracoDebug(specs=specs, draco=new_draco)
plotter = DracoDebugPlotter(debugger.chart_preferences)
# sort by sum of count
plotter.create_chart(cfg=DracoDebugChartConfig.SORT_BY_COUNT_SUM)
CHART 1
COST: [18]
{'field': [{'max': 35,
'min': -1,
'name': 'temp_max',
'type': 'number',
'unique': 100},
{'name': 'precipitation', 'type': 'number'}],
'number_rows': 100,
'task': 'summary',
'view': [{'coordinates': 'cartesian',
'mark': [{'encoding': [{'binning': 10,
'channel': 'x',
'field': 'temp_max'},
{'channel': 'y', 'field': 'precipitation'},
{'aggregate': 'count', 'channel': 'size'}],
'type': 'point'}],
'scale': [{'channel': 'x', 'type': 'linear'},
{'channel': 'y', 'type': 'ordinal'},
{'channel': 'size', 'type': 'linear', 'zero': 'true'}]}]}
CHART 2
COST: [19]
{'field': [{'max': 35,
'min': -1,
'name': 'temp_max',
'type': 'number',
'unique': 100},
{'name': 'precipitation', 'type': 'number'}],
'number_rows': 100,
'task': 'summary',
'view': [{'coordinates': 'cartesian',
'mark': [{'encoding': [{'binning': 10,
'channel': 'y',
'field': 'temp_max'},
{'binning': 10,
'channel': 'x',
'field': 'precipitation'},
{'aggregate': 'count', 'channel': 'size'}],
'type': 'point'}],
'scale': [{'channel': 'size', 'type': 'linear', 'zero': 'true'},
{'channel': 'x', 'type': 'linear'},
{'channel': 'y', 'type': 'linear'}]}]}
CHART 3
COST: [19]
{'field': [{'max': 35,
'min': -1,
'name': 'temp_max',
'type': 'number',
'unique': 100},
{'name': 'precipitation', 'type': 'number'}],
'number_rows': 100,
'task': 'summary',
'view': [{'coordinates': 'cartesian',
'mark': [{'encoding': [{'binning': 10,
'channel': 'y',
'field': 'temp_max'},
{'binning': 10,
'channel': 'x',
'field': 'precipitation'},
{'aggregate': 'count', 'channel': 'size'}],
'type': 'point'}],
'scale': [{'channel': 'size', 'type': 'linear', 'zero': 'true'},
{'channel': 'x', 'type': 'linear', 'zero': 'true'},
{'channel': 'y', 'type': 'linear'}]}]}
VIOLATED PREFERENCES:
Note that the first chart shouldn’t be preferred to the latter two because the precipitation
field with floating numbers shouldn’t be treated as discrete.
The difference of the scores results from the bin
, ordinal_scale
and binned_orientation_not_x
since they are all non-zero. Then with bin
and ordinal_scale
having the same count and weight, the only reason CHART 2 and 3 being ranked lower was the binned_oriented_not_x
. However, CHART 2 and 3 both do have binned x-axis. Realizing that binned_oriented_not_x
should have meant that “Prefer binned quantitative on x-axis if y-axis is not binned”, we fixed the soft constraint definition in soft.lp
as:
preference(binned_orientation_not_x,E) :-
attribute((field,type),F,(number;datetime)),
helper((encoding,field),E,F),
attribute((encoding,binning),E,_),
not attribute((encoding,channel),_,x).
d.count_preferences(specs["Rec 2"])
defaultdict(int,
{'cartesian_coordinate': 1,
'summary_point': 1,
'aggregate_count': 1,
'linear_size': 1,
'linear_y': 1,
'linear_x': 1,
'd_d_point': 1,
'linear_scale': 3,
'encoding_field': 2,
'encoding': 3,
'bin': 2,
'aggregate': 1})
Now, the CHART 2 is no longer violating the preference binned_orientation_not_x
.
Debugging empty results#
While we are exploring with the knowledge base with partial specifications as input, we may encounter the situation where Draco returns empty results.
If you see too few recommendations, you can check if some of your constraints are too tight, and move them to soft constraints. If you see no recommendations, you might have made mistakes in the hard constraints. You can allow violations to check what are the common ones by removing the violation constraint, which forbids any violations, from the programs. Below is an example:
for model in d.complete_spec(new_input):
spec = answer_set_to_dict(model.answer_set)
pprint(spec)
print("VIOLATED HARD CONSTRAINTS:")
answer = [str(symbol) + ". " for symbol in model.answer_set]
print(d.get_violations(answer))
{'field': [{'max': 35,
'min': -1,
'name': 'temp_max',
'type': 'number',
'unique': 100},
{'name': 'precipitation', 'type': 'number'}],
'number_rows': 100,
'task': 'summary',
'view': [{'coordinates': 'cartesian',
'mark': [{'encoding': [{'binning': 10,
'channel': 'x',
'field': 'temp_max'},
{'channel': 'y', 'field': 'precipitation'},
{'aggregate': 'count', 'channel': 'size'}],
'type': 'point'}],
'scale': [{'channel': 'x', 'type': 'linear'},
{'channel': 'y', 'type': 'ordinal'},
{'channel': 'size', 'type': 'linear', 'zero': 'true'}]}]}
VIOLATED HARD CONSTRAINTS:
[]
# `constraints_no_violation` gives access to the constraints without including the "violation" constraint
new_draco = Draco(constraints=d.constraints_no_violation)
for model in new_draco.complete_spec(new_input):
spec = answer_set_to_dict(model.answer_set)
pprint(spec)
print("VIOLATED HARD CONSTRAINTS:")
answer = [str(symbol) + ". " for symbol in model.answer_set]
print(new_draco.get_violations(answer))
{'field': [{'max': 35,
'min': -1,
'name': 'temp_max',
'type': 'number',
'unique': 100},
{'name': 'precipitation', 'type': 'number'}],
'number_rows': 100,
'task': 'summary',
'view': [{'coordinates': 'cartesian',
'mark': [{'encoding': [{'channel': 'detail', 'field': 'temp_max'},
{'channel': 'x', 'field': 'precipitation'},
{'channel': 'x'},
{'channel': 'y'},
{'channel': 'y'},
{'channel': 'x'}],
'type': 'rect'}],
'scale': [{'channel': 'detail', 'type': 'linear'},
{'channel': 'x', 'type': 'linear', 'zero': 'true'},
{'channel': 'x', 'type': 'linear', 'zero': 'true'}]}]}
VIOLATED HARD CONSTRAINTS:
['enforce_order', 'detail_not_ordinal', 'rect_without_d_d', 'detail_without_agg', 'encoding_no_field_and_not_count', 'encoding_channel_without_scale', 'scale_repeat_channel', 'encoding_repeat_channel']