Skip to content

Commit

Permalink
Categorize problem types
Browse files Browse the repository at this point in the history
  • Loading branch information
matejak committed Mar 20, 2024
1 parent 8d4c99d commit 1c4fea2
Show file tree
Hide file tree
Showing 11 changed files with 467 additions and 290 deletions.
102 changes: 2 additions & 100 deletions estimage/problems/__init__.py
Original file line number Diff line number Diff line change
@@ -1,100 +1,2 @@
import dataclasses
import typing

import numpy as np

from ..entities.card import BaseCard
from ..entities.model import EstiModel


@dataclasses.dataclass(init=False)
class Problem:
description: str
affected_cards_names: list[str]
tags: set[str]

def __init__(self):
self.description = ""
self.affected_cards_names = []
self.tags = frozenset()

def add_tag(self, tag):
self.tags = frozenset(self.tags.union([tag]))


class ProblemDetector:
POINT_THRESHOLD = 0.4

def __init__(self, model: EstiModel, cards: typing.Iterable[BaseCard]):
self.model = model
self.cards = cards
self.problems = []

self._get_problems()

def _get_problems(self):
for card in self.cards:
self._get_card_problem(card)

def _get_card_problem(self, card):
self._analyze_inconsistent_record(card)

def _numbers_differ_significantly(self, lhs, rhs):
if np.abs(lhs - rhs) > self.POINT_THRESHOLD:
return True

def _create_and_add_card_problem(self, card):
problem = Problem()
problem.affected_cards_names.append(card.name)
self.problems.append(problem)
return problem

def _card_is_not_estimated_but_has_children(self, card):
return card.children and card.point_cost == 0

def _treat_inconsistent_estimate(self, card, computed_nominal_cost, recorded_cost, expected_computed_cost):
problem = self._create_and_add_card_problem(card)
problem.add_tag("inconsistent_estimate")
if computed_nominal_cost == 0:
self._inconsistent_card_missing_estimates(problem, card, recorded_cost)
else:
self._inconsistent_card_differing_estimate(problem, card, recorded_cost, expected_computed_cost)

def _card_has_no_children_with_children(self, card):
for child in card.children:
if child.children:
return False
return True

def _inconsistent_card_missing_estimates(self, problem, card, recorded_cost):
problem.description = (
f"'{card.name}' has inconsistent recorded point cost of {recorded_cost:.2g}, "
f"as its children appear to lack estimations."
)
problem.add_tag("missing_estimates")
if self._card_has_no_children_with_children(card):
problem.add_tag("childless_children")

def _inconsistent_card_differing_estimate(self, problem, card, recorded_cost, expected_computed_cost):
if expected_computed_cost < recorded_cost:
problem.add_tag("sum_of_children_lower")
problem.description = (
f"'{card.name}' has inconsistent recorded point cost of {recorded_cost:.2g}, "
f"while the deduced cost is {expected_computed_cost:.2g}"
)

def _analyze_inconsistent_record(self, card):
recorded_cost = card.point_cost
computed_remaining_cost = self.model.remaining_point_estimate_of(card.name).expected
computed_nominal_cost = self.model.nominal_point_estimate_of(card.name).expected
expected_computed_cost = computed_nominal_cost
if card.children:
expected_computed_cost = computed_remaining_cost

if self._card_is_not_estimated_but_has_children(card):
return

if not self._numbers_differ_significantly(recorded_cost, expected_computed_cost):
return

self._treat_inconsistent_estimate(card, computed_nominal_cost, recorded_cost, expected_computed_cost)
from .problem import Problem, ProblemDetector
from .groups import ProblemClassifier
109 changes: 109 additions & 0 deletions estimage/problems/groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import collections
import typing

from .problem import Problem, get_problem
from . import solutions


class ProblemCategory:
name: str = "generic"
description: str = ""
solution: solutions.Solution = None

required_tags: typing.FrozenSet[str] = frozenset()
unwanted_tags: typing.FrozenSet[str] = frozenset()

def matches(self, p: Problem):
if p.tags.intersection(self.required_tags) != self.required_tags:
return False
if p.tags.intersection(self.unwanted_tags):
return False
return True

def _get_solution_of(self, p: Problem):
return self.solution(p)

def get_solution_of(self, p: Problem):
if not self.solution:
return None
return self._get_solution_of(p)


class ProblemClassifier:
CATEGORIES: typing.Mapping[str, ProblemCategory] = dict()
classified_problems: typing.Mapping[str, typing.List[Problem]]
_problems = typing.Mapping[Problem, str]

def __init__(self):
self.not_classified = []
self.CATEGORIES = dict(** self.CATEGORIES)
self.classified_problems = collections.defaultdict(list)
self._problems = dict()

def classify(self, problems: typing.Iterable[Problem]):
for p in problems:
self._classify_problem(p)

def _classify_problem(self, problem: Problem):
for c_name, c in self.CATEGORIES.items():
if c.matches(problem):
self.classified_problems[c_name].append(problem)
self._problems[problem] = c_name
return
self.not_classified.append(problem)

def get_category_of(self, problem: Problem):
cat_name = self._problems.get(problem, None)
return self.CATEGORIES.get(cat_name, ProblemCategory())

def get_categories_with_problems(self):
return [self.CATEGORIES[name] for name in self.classified_problems]

def add_category(self, cat_type: typing.Type[ProblemCategory]):
if (name := cat_type.name) in self.CATEGORIES:
msg = f"Already have a category named '{name}'"
raise KeyError(msg)
self.CATEGORIES[name] = cat_type()


def problem_category(cls):
ProblemClassifier.CATEGORIES[cls.name] = cls()
return cls


@problem_category
class ReasonableOutdated(ProblemCategory):
name = "reasonable_outdated"
description = "Estimate is inconsistent with children, but lower than the nominal size and greater than the size of tasks not yet completed."
solution = solutions.SolutionByUpdatingSelf

required_tags = frozenset([
"inconsistent_estimate",
"estimate_within_nominal",
"sum_of_children_lower",
])
unwanted_tags = frozenset([
"missing_estimates",
])


@problem_category
class GenericInconsistent(ProblemCategory):
name = "generic_inconsistent"
solution = solutions.SolutionByUpdatingSelf

required_tags = frozenset(["inconsistent_estimate"])
unwanted_tags = frozenset(["missing_estimates"])


@problem_category
class UnestimatedChildren(ProblemCategory):
name = "unestimated_children"
description = "Children have no size estimated, but the parent issue has."
solution = solutions.SolutionByUpdatingChildren

required_tags = set([
"inconsistent_estimate",
"missing_estimates",
"childless_children",
])
129 changes: 129 additions & 0 deletions estimage/problems/problem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import dataclasses
import typing
import collections

import numpy as np

from ..entities.card import BaseCard
from ..entities.model import EstiModel


TAG_TO_PROBLEM_TYPE = dict()

def get_problem(** data):
data["tags"] = frozenset(data.get("tags", frozenset()))
problem_tags = list(data["tags"].intersection(set(TAG_TO_PROBLEM_TYPE.keys())))
if not problem_tags:
return Problem(** data)
return TAG_TO_PROBLEM_TYPE[problem_tags[0]](** data)


def problem_associated_with_tag(tag):
def wrapper(wrapped):
TAG_TO_PROBLEM_TYPE[tag] = wrapped
return wrapped
return wrapper


@dataclasses.dataclass(frozen=True)
class Problem:
description: str = ""
affected_card_name: str = ""
tags: typing.FrozenSet[str] = frozenset()

def format_task_name(self):
return self.affected_card_name


@problem_associated_with_tag("inconsistent_estimate")
@dataclasses.dataclass(frozen=True)
class ValueProblem(Problem):
value_expected: float = None
value_found: float = None


class ProblemDetector:
POINT_THRESHOLD = 0.4

def __init__(self, model: EstiModel, cards: typing.Iterable[BaseCard]):
self.model = model
self.cards = cards
self.problems = []

self._get_problems()

def _get_problems(self):
for card in self.cards:
self._get_card_problem(card)

def _get_card_problem(self, card):
self._analyze_inconsistent_record(card)

def _numbers_differ_significantly(self, lhs, rhs):
if np.abs(lhs - rhs) > self.POINT_THRESHOLD:
return True

def _create_card_problem_data(self, card):
data = dict()
data["affected_card_name"] = card.name
data["tags"] = set()
return data

def _card_is_not_estimated_but_has_children(self, card):
return card.children and card.point_cost == 0

def _treat_inconsistent_estimate(self, card, computed_nominal_cost, recorded_cost, expected_computed_cost):
data = self._create_card_problem_data(card)
data["tags"].add("inconsistent_estimate")
data["value_found"] = recorded_cost
if computed_nominal_cost == 0:
self._inconsistent_card_missing_children_estimates(data, card)
else:
self._inconsistent_card_differing_estimate(data, card, expected_computed_cost, computed_nominal_cost)
data["tags"] = frozenset(data["tags"])
self.problems.append(get_problem(** data))

def _card_has_no_children_with_children(self, card):
for child in card.children:
if child.children:
return False
return True

def _inconsistent_card_missing_children_estimates(self, data, card):
recorded_cost = data['value_found']
data["description"] = (
f"'{card.name}' has inconsistent recorded point cost of {recorded_cost:.2g}, "
f"as its children appear to lack estimations."
)
data["tags"].add("missing_children_estimates")
if self._card_has_no_children_with_children(card):
data["tags"].add("has_only_childless_children")

def _inconsistent_card_differing_estimate(self, data, card, expected_computed_cost, computed_nominal_cost):
recorded_cost = data['value_found']
if expected_computed_cost < recorded_cost:
data["tags"].add("sum_of_children_lower")
if recorded_cost <= computed_nominal_cost:
data["tags"].add("estimate_within_nominal")
data["description"] = (
f"'{card.name}' has inconsistent recorded point cost of {recorded_cost:.2g}, "
f"while the deduced cost is {expected_computed_cost:.2g}"
)
data["value_expected"] = expected_computed_cost

def _analyze_inconsistent_record(self, card):
recorded_cost = card.point_cost
computed_remaining_cost = self.model.remaining_point_estimate_of(card.name).expected
computed_nominal_cost = self.model.nominal_point_estimate_of(card.name).expected
expected_computed_cost = computed_nominal_cost
if card.children:
expected_computed_cost = computed_remaining_cost

if self._card_is_not_estimated_but_has_children(card):
return

if not self._numbers_differ_significantly(recorded_cost, expected_computed_cost):
return

self._treat_inconsistent_estimate(card, computed_nominal_cost, recorded_cost, expected_computed_cost)

47 changes: 47 additions & 0 deletions estimage/problems/solutions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import typing

from ..entities.card import BaseCard
from .problem import Problem


class Solution:
card_name: str
description: str = ""

def __init__(self, problem: Problem):
self.problem = problem

def describe(self):
return ""


class SolutionByUpdating(Solution):
updates_model: bool

def __init__(self, problem: Problem):
super().__init__(problem)
self.updates_model = True

def describe(self):
return f"Update the record of '{self.card_name}'"


class SolutionByUpdatingChildren(SolutionByUpdating):
action = "update_children_points"
description = "Update children of respective card, so the subtree is consistent"

def describe(self):
return f"Update children of '{self.card_name}', so they become consistent with the its record."


class SolutionByUpdatingSelf(SolutionByUpdating):
action = "update_points"
description = "Update the respective card, so it is consistent with its children"
value: float

def __init__(self, problem: Problem):
super().__init__(problem)
self.updates_model = False

def describe(self):
return f"Update the record of '{self.card_name}', so it matches records of its children."
Loading

0 comments on commit 1c4fea2

Please sign in to comment.