1 # DExTer : Debugging Experience Tester
4 # Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
5 # See https://llvm.org/LICENSE.txt for license information.
6 # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
7 """Calculate a 'score' based on some dextIR.
8 Assign penalties based on different commands to decrease the score.
9 1.000 would be a perfect score.
10 0.000 is the worst theoretical score possible.
13 from collections
import defaultdict
, namedtuple
, Counter
16 from itertools
import groupby
17 from dex
.command
.StepValueInfo
import StepValueInfo
18 from dex
.command
.commands
.DexExpectWatchBase
import format_address
21 PenaltyCommand
= namedtuple("PenaltyCommand", ["pen_dict", "max_penalty"])
22 # 'meta' field used in different ways by different things
23 PenaltyInstance
= namedtuple("PenaltyInstance", ["meta", "the_penalty"])
26 def add_heuristic_tool_arguments(parser
):
28 "--penalty-variable-optimized",
31 help="set the penalty multiplier for each"
32 " occurrence of a variable that was optimized"
37 "--penalty-misordered-values",
40 help="set the penalty multiplier for each" " occurrence of a misordered value.",
44 "--penalty-irretrievable",
47 help="set the penalty multiplier for each"
48 " occurrence of a variable that couldn't"
53 "--penalty-not-evaluatable",
56 help="set the penalty multiplier for each"
57 " occurrence of a variable that couldn't"
62 "--penalty-missing-values",
65 help="set the penalty multiplier for each missing" " value",
69 "--penalty-incorrect-values",
72 help="set the penalty multiplier for each"
73 " occurrence of an unexpected value.",
77 "--penalty-unreachable",
79 default
=4, # XXX XXX XXX selected by random
80 help="set the penalty for each line stepped onto that should"
81 " have been unreachable.",
85 "--penalty-misordered-steps",
87 default
=2, # XXX XXX XXX selected by random
88 help="set the penalty for differences in the order of steps"
89 " the program was expected to observe.",
93 "--penalty-missing-step",
95 default
=4, # XXX XXX XXX selected by random
96 help="set the penalty for the program skipping over a step.",
100 "--penalty-incorrect-program-state",
102 default
=4, # XXX XXX XXX selected by random
103 help="set the penalty for the program never entering an expected state"
104 " or entering an unexpected state.",
109 class PenaltyLineRanges
:
110 def __init__(self
, first_step
, penalty
):
111 self
.ranges
= [(first_step
, first_step
)]
112 self
.penalty
= penalty
114 def add_step(self
, next_step
, penalty
):
115 last_range
= self
.ranges
[-1]
116 last_step
= last_range
[1]
117 if next_step
== last_step
+ 1:
118 self
.ranges
[-1] = (last_range
[0], next_step
)
120 self
.ranges
.append((next_step
, next_step
))
121 self
.penalty
+= penalty
124 range_to_str
= lambda r
: str(r
[0]) if r
[0] == r
[1] else f
"{r[0]}-{r[1]}"
125 if self
.ranges
[0][0] == self
.ranges
[-1][1]:
126 text
= f
"step {self.ranges[0][0]}"
128 step_list
= ", ".join([range_to_str(r
) for r
in self
.ranges
])
129 text
= f
"steps [{step_list}]"
131 text
+= " <r>[-{}]</>".format(self
.penalty
)
135 class Heuristic(object):
136 def __init__(self
, context
, steps
):
137 self
.context
= context
139 self
.address_resolutions
= {}
143 self
.penalty_variable_optimized
,
144 self
.penalty_irretrievable
,
145 self
.penalty_not_evaluatable
,
146 self
.penalty_incorrect_values
,
147 self
.penalty_missing_values
,
148 self
.penalty_unreachable
,
149 self
.penalty_missing_step
,
150 self
.penalty_misordered_steps
,
154 # Before evaluating scoring commands, evaluate address values.
156 for command
in steps
.commands
["DexDeclareAddress"]:
157 command
.address_resolutions
= self
.address_resolutions
162 # Get DexExpectWatchType results.
164 for command
in steps
.commands
["DexExpectWatchType"]:
166 maximum_possible_penalty
= min(3, len(command
.values
)) * worst_penalty
167 name
, p
= self
._calculate
_expect
_watch
_penalties
(
168 command
, maximum_possible_penalty
170 name
= name
+ " ExpectType"
171 self
.penalties
[name
] = PenaltyCommand(p
, maximum_possible_penalty
)
175 # Get DexExpectWatchValue results.
177 for command
in steps
.commands
["DexExpectWatchValue"]:
178 command
.address_resolutions
= self
.address_resolutions
180 maximum_possible_penalty
= min(3, len(command
.values
)) * worst_penalty
181 name
, p
= self
._calculate
_expect
_watch
_penalties
(
182 command
, maximum_possible_penalty
184 name
= name
+ " ExpectValue"
185 self
.penalties
[name
] = PenaltyCommand(p
, maximum_possible_penalty
)
190 penalties
= defaultdict(list)
191 maximum_possible_penalty_all
= 0
192 for expect_state
in steps
.commands
["DexExpectProgramState"]:
193 success
= expect_state
.eval(steps
)
194 p
= 0 if success
else self
.penalty_incorrect_program_state
196 meta
= "expected {}: {}".format(
197 "{} times".format(expect_state
.times
)
198 if expect_state
.times
>= 0
199 else "at least once",
200 expect_state
.program_state_text
,
204 meta
= "<g>{}</>".format(meta
)
206 maximum_possible_penalty
= self
.penalty_incorrect_program_state
207 maximum_possible_penalty_all
+= maximum_possible_penalty
208 name
= expect_state
.program_state_text
210 PenaltyInstance("{} times".format(len(expect_state
.encounters
)), p
)
212 self
.penalties
["expected program states"] = PenaltyCommand(
213 penalties
, maximum_possible_penalty_all
218 # Get the total number of each step kind.
219 step_kind_counts
= defaultdict(int)
220 for step
in getattr(steps
, "steps"):
221 step_kind_counts
[step
.step_kind
] += 1
223 # Get DexExpectStepKind results.
224 penalties
= defaultdict(list)
225 maximum_possible_penalty_all
= 0
227 for command
in steps
.commands
["DexExpectStepKind"]:
229 # Cap the penalty at 2 * expected count or else 1
230 maximum_possible_penalty
= max(command
.count
* 2, 1)
231 p
= abs(command
.count
- step_kind_counts
[command
.name
])
232 actual_penalty
= min(p
, maximum_possible_penalty
)
234 "{}".format(command
.name
)
236 else "<g>{}</>".format(command
.name
)
238 penalties
[key
] = [PenaltyInstance(p
, actual_penalty
)]
239 maximum_possible_penalty_all
+= maximum_possible_penalty
240 self
.penalties
["step kind differences"] = PenaltyCommand(
241 penalties
, maximum_possible_penalty_all
246 if "DexUnreachable" in steps
.commands
:
247 cmds
= steps
.commands
["DexUnreachable"]
250 # Find steps with unreachable in them
251 ureachs
= [s
for s
in steps
.steps
if "DexUnreachable" in s
.watches
.keys()]
253 # There's no need to match up cmds with the actual watches
254 upen
= self
.penalty_unreachable
256 count
= upen
* len(ureachs
)
260 msg
= "line {} reached".format(x
.current_location
.lineno
)
261 d
[msg
] = [PenaltyInstance(upen
, upen
)]
263 d
= {"<g>No unreachable lines seen</>": [PenaltyInstance(0, 0)]}
264 total
= PenaltyCommand(d
, len(cmds
) * upen
)
266 self
.penalties
["unreachable lines"] = total
268 if "DexExpectStepOrder" in steps
.commands
:
269 cmds
= steps
.commands
["DexExpectStepOrder"]
271 # Form a list of which line/cmd we _should_ have seen
272 cmd_num_lst
= [(x
, c
.get_line()) for c
in cmds
for x
in c
.sequence
]
273 # Order them by the sequence number
274 cmd_num_lst
.sort(key
=lambda t
: t
[0])
275 # Strip out sequence key
276 cmd_num_lst
= [y
for x
, y
in cmd_num_lst
]
278 # Now do the same, but for the actually observed lines/cmds
280 deso
= [s
for s
in ss
if "DexExpectStepOrder" in s
.watches
.keys()]
281 deso
= [s
.watches
["DexExpectStepOrder"] for s
in deso
]
282 # We rely on the steps remaining in order here
283 order_list
= [int(x
.expression
) for x
in deso
]
285 # First off, check to see whether or not there are missing items
286 expected
= Counter(cmd_num_lst
)
287 seen
= Counter(order_list
)
289 unseen_line_dict
= dict()
290 skipped_line_dict
= dict()
292 mispen
= self
.penalty_missing_step
295 for k
, v
in expected
.items():
297 msg
= "Line {} not seen".format(k
)
298 unseen_line_dict
[msg
] = [PenaltyInstance(mispen
, mispen
)]
301 msg
= "Line {} skipped at least once".format(k
)
302 skipped_line_dict
[msg
] = [PenaltyInstance(mispen
, mispen
)]
303 num_missing
+= v
- seen
[k
]
305 # Don't penalise unexpected extra sightings of a line
307 num_repeats
= seen
[k
] - v
310 if len(unseen_line_dict
) == 0:
311 pi
= PenaltyInstance(0, 0)
312 unseen_line_dict
["<g>All lines were seen</>"] = [pi
]
314 if len(skipped_line_dict
) == 0:
315 pi
= PenaltyInstance(0, 0)
316 skipped_line_dict
["<g>No lines were skipped</>"] = [pi
]
318 total
= PenaltyCommand(unseen_line_dict
, len(expected
) * mispen
)
319 self
.penalties
["Unseen lines"] = total
320 total
= PenaltyCommand(skipped_line_dict
, len(expected
) * mispen
)
321 self
.penalties
["Skipped lines"] = total
323 ordpen
= self
.penalty_misordered_steps
324 cmd_num_lst
= [str(x
) for x
in cmd_num_lst
]
325 order_list
= [str(x
) for x
in order_list
]
326 lst
= list(difflib
.Differ().compare(cmd_num_lst
, order_list
))
327 diff_detail
= Counter(l
[0] for l
in lst
)
329 assert "?" not in diff_detail
331 # Diffs are hard to interpret; there are many algorithms for
332 # condensing them. Ignore all that, and just print out the changed
333 # sequences, it's up to the user to interpret what's going on.
335 def filt_lines(s
, seg
, e
, key
):
339 lst
.append(int(x
[2:]))
345 def reportdiff(start_idx
, segment
, end_idx
):
346 msg
= "Order mismatch, expected linenos {}, saw {}"
347 expected_linenos
= filt_lines(start_idx
, segment
, end_idx
, "-")
348 seen_linenos
= filt_lines(start_idx
, segment
, end_idx
, "+")
349 msg
= msg
.format(expected_linenos
, seen_linenos
)
350 diff_msgs
[msg
] = [PenaltyInstance(ordpen
, ordpen
)]
352 # Group by changed segments.
356 for k
, subit
in groupby(lst
, lambda x
: x
[0] == " "):
357 if k
: # Whitespace group
358 nochanged
= [x
for x
in subit
]
359 end_expt_step
= int(nochanged
[0][2:])
360 if len(to_print_lst
) > 0:
361 reportdiff(start_expt_step
, to_print_lst
, end_expt_step
)
362 start_expt_step
= int(nochanged
[-1][2:])
364 else: # Diff group, save for printing
365 to_print_lst
= [x
for x
in subit
]
367 # If there was a dangling different step, print that too.
368 if len(to_print_lst
) > 0:
369 reportdiff(start_expt_step
, to_print_lst
, "[End]")
371 if len(diff_msgs
) == 0:
372 diff_msgs
["<g>No lines misordered</>"] = [PenaltyInstance(0, 0)]
373 total
= PenaltyCommand(diff_msgs
, len(cmd_num_lst
) * ordpen
)
374 self
.penalties
["Misordered lines"] = total
378 def _calculate_expect_watch_penalties(self
, c
, maximum_possible_penalty
):
379 penalties
= defaultdict(list)
381 if c
.line_range
[0] == c
.line_range
[-1]:
382 line_range
= str(c
.line_range
[0])
384 line_range
= "{}-{}".format(c
.line_range
[0], c
.line_range
[-1])
386 name
= "{}:{} [{}]".format(os
.path
.basename(c
.path
), line_range
, c
.expression
)
388 num_actual_watches
= len(c
.expected_watches
) + len(c
.unexpected_watches
)
390 penalty_available
= maximum_possible_penalty
392 # Only penalize for missing values if we have actually seen a watch
393 # that's returned us an actual value at some point, or if we've not
394 # encountered the value at all.
395 if num_actual_watches
or c
.times_encountered
== 0:
396 for v
in c
.missing_values
:
397 current_penalty
= min(penalty_available
, self
.penalty_missing_values
)
398 penalty_available
-= current_penalty
399 penalties
["missing values"].append(PenaltyInstance(v
, current_penalty
))
401 for v
in c
.encountered_values
:
402 penalties
["<g>expected encountered watches</>"].append(
403 PenaltyInstance(v
, 0)
406 penalty_descriptions
= [
407 (self
.penalty_not_evaluatable
, c
.invalid_watches
, "could not evaluate"),
409 self
.penalty_variable_optimized
,
410 c
.optimized_out_watches
,
411 "result optimized away",
413 (self
.penalty_misordered_values
, c
.misordered_watches
, "misordered result"),
415 self
.penalty_irretrievable
,
416 c
.irretrievable_watches
,
417 "result could not be retrieved",
419 (self
.penalty_incorrect_values
, c
.unexpected_watches
, "unexpected result"),
422 for penalty_score
, watches
, description
in penalty_descriptions
:
423 # We only penalize the encountered issue for each missing value per
424 # command but we still want to record each one, so set the penalty
425 # to 0 after the threshold is passed.
426 times_to_penalize
= len(c
.missing_values
)
429 times_to_penalize
-= 1
430 penalty_score
= min(penalty_available
, penalty_score
)
431 penalty_available
-= penalty_score
432 penalties
[description
].append(PenaltyInstance(w
, penalty_score
))
433 if not times_to_penalize
:
436 return name
, penalties
442 maximum_allowed_penalty
= 0
443 for name
, pen_cmd
in self
.penalties
.items():
444 maximum_allowed_penalty
+= pen_cmd
.max_penalty
445 value
= pen_cmd
.pen_dict
446 for category
, inst_list
in value
.items():
447 result
+= sum(x
.the_penalty
for x
in inst_list
)
448 return min(result
, maximum_allowed_penalty
)
451 def max_penalty(self
):
452 return sum(p_cat
.max_penalty
for p_cat
in self
.penalties
.values())
457 return 1.0 - (self
.penalty
/ float(self
.max_penalty
))
458 except ZeroDivisionError:
462 def summary_string(self
):
464 isnan
= score
!= score
# pylint: disable=comparison-with-itself
466 if score
< 0.25 or isnan
:
471 return "<{}>({:.4f})</>".format(color
, score
)
474 def verbose_output(self
): # noqa
477 # Add address resolutions if present.
478 if self
.address_resolutions
:
479 if self
.resolved_addresses
:
480 string
+= "\nResolved Addresses:\n"
481 for addr
, res
in self
.resolved_addresses
.items():
482 string
+= f
" '{addr}': {res}\n"
483 if self
.unresolved_addresses
:
485 string
+= f
"Unresolved Addresses:\n {self.unresolved_addresses}\n"
488 for command
in sorted(self
.penalties
):
489 pen_cmd
= self
.penalties
[command
]
490 maximum_possible_penalty
= pen_cmd
.max_penalty
493 for category
in sorted(pen_cmd
.pen_dict
):
494 lines
.append(" <r>{}</>:\n".format(category
))
496 step_value_results
= {}
497 for result
, penalty
in pen_cmd
.pen_dict
[category
]:
498 if not isinstance(result
, StepValueInfo
):
500 if result
.expected_value
not in step_value_results
:
501 step_value_results
[result
.expected_value
] = PenaltyLineRanges(
502 result
.step_index
, penalty
505 step_value_results
[result
.expected_value
].add_step(
506 result
.step_index
, penalty
509 for value
, penalty_line_range
in step_value_results
.items():
510 text
= f
"({value}): {penalty_line_range}"
511 total_penalty
+= penalty_line_range
.penalty
512 lines
.append(" {}\n".format(text
))
514 for result
, penalty
in pen_cmd
.pen_dict
[category
]:
515 if isinstance(result
, StepValueInfo
):
520 assert penalty
> 0, penalty
521 total_penalty
+= penalty
522 text
+= " <r>[-{}]</>".format(penalty
)
523 lines
.append(" {}\n".format(text
))
527 string
+= " <b>{}</> <y>[{}/{}]</>\n".format(
528 command
, total_penalty
, maximum_possible_penalty
536 def resolved_addresses(self
):
538 addr
: format_address(res
)
539 for addr
, res
in self
.address_resolutions
.items()
544 def unresolved_addresses(self
):
545 return [addr
for addr
, res
in self
.address_resolutions
.items() if res
is None]
548 def penalty_variable_optimized(self
):
549 return self
.context
.options
.penalty_variable_optimized
552 def penalty_irretrievable(self
):
553 return self
.context
.options
.penalty_irretrievable
556 def penalty_not_evaluatable(self
):
557 return self
.context
.options
.penalty_not_evaluatable
560 def penalty_incorrect_values(self
):
561 return self
.context
.options
.penalty_incorrect_values
564 def penalty_missing_values(self
):
565 return self
.context
.options
.penalty_missing_values
568 def penalty_misordered_values(self
):
569 return self
.context
.options
.penalty_misordered_values
572 def penalty_unreachable(self
):
573 return self
.context
.options
.penalty_unreachable
576 def penalty_missing_step(self
):
577 return self
.context
.options
.penalty_missing_step
580 def penalty_misordered_steps(self
):
581 return self
.context
.options
.penalty_misordered_steps
584 def penalty_incorrect_program_state(self
):
585 return self
.context
.options
.penalty_incorrect_program_state